'
- $cellMatches = [regex]::Matches($rowContent, $cellPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
-
- if ($cellMatches.Count -ge 2) {
- $modelName = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
- $secondTdContent = $cellMatches[1].Groups[1].Value.Trim()
- # $linkPattern = ']+href="([^"]+)"[^>]*>'
- # Change linkPattern to match https://www.microsoft.com/download/details.aspx?id=
- $linkPattern = ']+href="(https://www\.microsoft\.com/download/details\.aspx\?id=\d+)"[^>]*>'
- $linkMatch = [regex]::Match($secondTdContent, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
-
- if ($linkMatch.Success) {
- $modelLink = $linkMatch.Groups[1].Value
- }
- else {
- continue
- }
-
- $models += [PSCustomObject]@{
- Make = 'Microsoft'
- Model = $modelName
- Link = $modelLink
- }
- }
- }
- }
- }
- WriteLog "Parsing complete. Found $($models.Count) models."
-
- # Persist model list (Source B) into the local cache for fast UI population.
- try {
- $cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
- $cache.ModelIndex = @($models)
- Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
- WriteLog "Surface cache: Saved Microsoft model list to cache."
- }
- catch {
- WriteLog "Surface cache: Failed to save Microsoft model list. Error: $($_.Exception.Message)"
- }
-
- return $models
- }
- catch {
- WriteLog "Error getting Microsoft models: $($_.Exception.Message)"
- throw "Failed to retrieve Microsoft Surface models."
- }
+ # Keep the UI signature unchanged while using the shared Learn-based source.
+ return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
}
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask {
From d6361dac4d803c6b03f42f14edc12fe866d54563 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Tue, 24 Mar 2026 16:28:29 -0700
Subject: [PATCH 12/30] Adds automatic column resizing to ListViews
- Enables automatic horizontal and vertical scrollbars for ListViews in the UI to improve navigation.
- Introduces functions to dynamically calculate and apply column widths based on the visible content and header text.
- Triggers column auto-resizing across various modules whenever ListView data is updated or refreshed.
- Renames a path normalization function and updates an event handler parameter name for clarity.
---
FFUDevelopment/BuildFFUVM_UI.xaml | 8 +-
.../FFUUI.Core/FFUUI.Core.Applications.psm1 | 4 +
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 11 +-
.../FFUUI.Core/FFUUI.Core.Drivers.psm1 | 3 +
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 3 +-
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 18 ++
.../FFUUI.Core/FFUUI.Core.Shared.psm1 | 209 ++++++++++++++++++
.../FFUUI.Core/FFUUI.Core.Winget.psm1 | 2 +
FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 1 +
9 files changed, 251 insertions(+), 8 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 672ca38..4c0c842 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -576,7 +576,7 @@
-
+
@@ -616,7 +616,7 @@
-
+
@@ -938,7 +938,7 @@
-
+
@@ -965,7 +965,7 @@
-
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1
index 15fbdaa..207be64 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Applications.psm1
@@ -182,6 +182,7 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes
$listView.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $listView
# Reset state
$State.Data.editingBYOApplication = $null
@@ -212,6 +213,7 @@ function Add-BYOApplication {
CopyStatus = ""
}
$listView.Items.Add($application)
+ Request-ListViewColumnAutoResize -ListView $listView
}
# Clear form and update button states for both add and update operations
@@ -285,6 +287,7 @@ function Add-AppsScriptVariable {
}
$State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state
@@ -311,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
}
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index 4224a55..0ba666f 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -669,6 +669,7 @@ function Update-UIFromConfig {
}
# Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
+ Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -737,6 +738,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstUSBDrives.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -797,6 +799,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstAdditionalFFUs.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
@@ -856,7 +859,7 @@ function Invoke-RestoreDefaults {
$rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings
- function Normalize-PathScalar {
+ function Get-PathScalar {
param([object]$value)
if ($null -eq $value) { return $null }
if ($value -is [System.Array]) {
@@ -871,14 +874,14 @@ function Invoke-RestoreDefaults {
}
$appsPath = Join-Path $rootPath 'Apps'
- $driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
+ $driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers'
}
else {
$driversPath = $driversRaw
}
- $ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
+ $ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
@@ -1060,6 +1063,7 @@ function Import-ConfigSupplementalAssets {
})
}
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
@@ -1194,6 +1198,7 @@ function Import-ConfigSupplementalAssets {
}
}
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) {
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.psm1
index 40c207d..7ebd440 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.psm1
@@ -373,6 +373,7 @@ function Search-DriverModels {
}
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0
if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ }
@@ -715,6 +716,7 @@ function Import-DriversJson {
# Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -786,6 +788,7 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) {
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 71d57c7..3d67c22 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -24,7 +24,7 @@ function Register-EventHandlers {
# Define a handler to validate pasted text, ensuring it's only integers
$integerPastingHandler = {
- param($sender, $pastingEventArgs)
+ param($eventSource, $pastingEventArgs)
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
$pastedText = $pastingEventArgs.DataObject.GetData([string])
# Check if the pasted text consists ONLY of one or more digits.
@@ -345,6 +345,7 @@ function Register-EventHandlers {
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
}
+ Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
$localState.Controls.lstUSBDrives.SelectedIndex = 0
}
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index b853cd9..94c89b9 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -591,6 +591,9 @@ function Initialize-DynamicUIElements {
}
)
+ # Keep driver model columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels -FixedColumnIndexes @(0)
+
# Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
@@ -750,6 +753,9 @@ function Initialize-DynamicUIElements {
}
)
+ # Keep Winget result columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults -FixedColumnIndexes @(0)
+
# BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView
@@ -773,6 +779,9 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
+ # Keep BYO application columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
+
# Apps Script Variables ListView setup
# Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
@@ -826,6 +835,9 @@ function Initialize-DynamicUIElements {
}
}
)
+
+ # Keep apps script variable columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -899,6 +911,9 @@ function Initialize-DynamicUIElements {
}
}
)
+
+ # Keep USB drive columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -945,6 +960,9 @@ function Initialize-DynamicUIElements {
}
}
)
+
+ # Keep additional FFU columns sized to the current visible content.
+ Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
index 12425bc..2ccabde 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
}
}
$ListView.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $ListView
}
# Function to move selected item to the top
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change
+ Request-ListViewColumnAutoResize -ListView $ListView
}
else {
# Log if item not found (for debugging)
@@ -494,6 +496,209 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
}
+# Function to request a deferred GridView column auto-size pass
+function Request-ListViewColumnAutoResize {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.Controls.ListView]$ListView
+ )
+
+ # Skip startup calls until the visual tree has finished loading.
+ if (-not $ListView.IsLoaded) {
+ return
+ }
+
+ # Ensure the ListView has registered auto-resize metadata before scheduling work.
+ $autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
+ if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
+ return
+ }
+
+ if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
+ return
+ }
+
+ $autoResizeState = $ListView.Resources[$autoResizeStateKey]
+ if ($autoResizeState.ResizePending) {
+ return
+ }
+
+ $autoResizeState.ResizePending = $true
+ $previousErrorActionPreference = $ErrorActionPreference
+
+ try {
+ $ErrorActionPreference = 'Stop'
+
+ $gridView = [System.Windows.Controls.GridView]$ListView.View
+ $fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
+ $visibleItems = [System.Collections.Generic.List[object]]::new()
+
+ if ($null -ne $ListView.ItemsSource) {
+ $collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
+ if ($null -ne $collectionView) {
+ foreach ($visibleItem in $collectionView) {
+ $visibleItems.Add($visibleItem)
+ }
+ }
+ }
+ else {
+ foreach ($visibleItem in $ListView.Items) {
+ $visibleItems.Add($visibleItem)
+ }
+ }
+
+ $ListView.UpdateLayout()
+
+ $columnIndex = 0
+ foreach ($column in $gridView.Columns) {
+ if ($fixedColumnIndexes -contains $columnIndex) {
+ $columnIndex++
+ continue
+ }
+
+ if ($null -eq $column) {
+ $columnIndex++
+ continue
+ }
+
+ $headerText = ""
+ $propertyName = $null
+
+ if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
+ $propertyName = [string]$column.DisplayMemberBinding.Path.Path
+ }
+
+ if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
+ if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
+ $headerText = [string]$column.Header.Content
+ }
+ if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
+ $propertyName = [string]$column.Header.Tag
+ }
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
+ $headerText = [string]$column.Header
+ }
+
+ if ([string]::IsNullOrWhiteSpace($headerText)) {
+ $headerText = $propertyName
+ }
+
+ $headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
+ $headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
+ $headerMeasureBlock.FontFamily = $ListView.FontFamily
+ $headerMeasureBlock.FontSize = $ListView.FontSize
+ $headerMeasureBlock.FontStyle = $ListView.FontStyle
+ $headerMeasureBlock.FontWeight = $ListView.FontWeight
+ $headerMeasureBlock.FontStretch = $ListView.FontStretch
+ $headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
+
+ $calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
+
+ foreach ($item in $visibleItems) {
+ if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
+ continue
+ }
+
+ $itemProperty = $null
+ if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
+ $matchedProperties = $item.PSObject.Properties.Match($propertyName)
+ if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
+ $itemProperty = $matchedProperties | Select-Object -First 1
+ }
+ }
+
+ if ($null -eq $itemProperty) {
+ continue
+ }
+
+ $itemText = [string]$itemProperty.Value
+ $extraWidth = 28
+
+ switch ($propertyName) {
+ 'Architecture' {
+ $extraWidth = 52
+ }
+ 'AdditionalExitCodes' {
+ $extraWidth = 44
+ }
+ 'IgnoreNonZeroExitCodes' {
+ $itemText = ' '
+ $extraWidth = 48
+ }
+ 'IgnoreExitCodes' {
+ $extraWidth = 28
+ }
+ }
+
+ $itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
+ $itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
+ $itemMeasureBlock.FontFamily = $ListView.FontFamily
+ $itemMeasureBlock.FontSize = $ListView.FontSize
+ $itemMeasureBlock.FontStyle = $ListView.FontStyle
+ $itemMeasureBlock.FontWeight = $ListView.FontWeight
+ $itemMeasureBlock.FontStretch = $ListView.FontStretch
+ $itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
+
+ $itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
+ if ($itemWidth -gt $calculatedWidth) {
+ $calculatedWidth = $itemWidth
+ }
+ }
+
+ if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
+ $calculatedWidth = [math]::Max($calculatedWidth, 120)
+ }
+
+ $column.Width = [math]::Max($calculatedWidth, 40)
+ $columnIndex++
+ }
+ }
+ catch {
+ WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
+ if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
+ WriteLog $_.InvocationInfo.PositionMessage
+ }
+ if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
+ WriteLog $_.ScriptStackTrace
+ }
+ }
+ finally {
+ $ErrorActionPreference = $previousErrorActionPreference
+ $autoResizeState.ResizePending = $false
+ }
+}
+
+# Function to enable reusable auto-resizing for GridView-backed ListViews
+function Enable-ListViewColumnAutoResize {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [System.Windows.Controls.ListView]$ListView,
+
+ [int[]]$FixedColumnIndexes = @()
+ )
+
+ $autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
+
+ # Only GridView-backed lists can participate in column auto-sizing.
+ if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
+ WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
+ return
+ }
+
+ if ($ListView.Resources.Contains($autoResizeStateKey)) {
+ return
+ }
+
+ $autoResizeState = [PSCustomObject]@{
+ FixedColumnIndexes = @($FixedColumnIndexes)
+ ResizePending = $false
+ }
+ $ListView.Resources[$autoResizeStateKey] = $autoResizeState
+}
+
# Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState {
param(
@@ -573,6 +778,7 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -743,6 +949,8 @@ function Invoke-ListViewSort {
$newView.Filter = $existingFilter
}
}
+
+ Request-ListViewColumnAutoResize -ListView $listView
}
# --------------------------------------------------------------------------
@@ -1043,6 +1251,7 @@ function Clear-ListViewContent {
}
$ListViewControl.Items.Refresh()
+ Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes
if ($null -ne $TextBoxesToClear) {
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1
index 3adc7af..d96b286 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1
@@ -59,6 +59,7 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text
$statusText = ""
@@ -178,6 +179,7 @@ function Import-WingetList {
}
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
+ Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
[System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index 8cba575..8da5c59 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -268,6 +268,7 @@ function Update-AdditionalFFUList {
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
}
+ Request-ListViewColumnAutoResize -ListView $listView
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
From 6db0f8c905bc60ff248b1daf83d49471e5786333 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Wed, 25 Mar 2026 10:39:57 -0700
Subject: [PATCH 13/30] Reverts to using hardcoded Lenovo PSREF token
Switches back to utilizing the static cookie for Lenovo PSREF requests, as the token has remained unchanged for months. The dynamic retrieval function call is bypassed to streamline the process but remains in the codebase as a fallback in case the token expires or changes in the future.
---
FFUDevelopment/BuildFFUVM.ps1 | 5 +++--
FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 | 5 +++--
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index f7d16f8..df4907e 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -1205,10 +1205,11 @@ function Get-LenovoDrivers {
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
- # $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
+ $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
- $lenovoCookie = Get-LenovoPSREFToken
+ # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
+ # $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1
index bb08448..187cd38 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1
@@ -23,10 +23,11 @@ function Get-LenovoDriversModelList {
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
- # $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
+ $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
- $lenovoCookie = Get-LenovoPSREFToken
+ # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
+ # $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie
From c135ad0fbac22fe98e940ccf43eef930c173db61 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 26 Mar 2026 22:31:08 -0700
Subject: [PATCH 14/30] Captures FFU directly from host-mounted VHDX
By optimizing and mounting the VHDX directly on the host for image capture, the build process no longer needs to boot the VM into WinPE, create SMB network shares, generate temporary local accounts, or rely on complex Hyper-V switch IP configurations. This streamlines the workflow and eliminates multiple networking and permission-related points of failure.
This change also removes the need to generate and attach WinPE capture media. All related parameters (`ShareName`, `Username`, `VMHostIPAddress`, `CreateCaptureMedia`, `CleanupCaptureISO`), UI controls, capture scripts, and documentation references have been removed or updated to reflect the simplified architecture.
---
FFUDevelopment/BuildFFUVM.ps1 | 478 +++++-------------
FFUDevelopment/BuildFFUVM_UI.xaml | 23 +-
FFUDevelopment/Create-PEMedia.ps1 | 75 +--
FFUDevelopment/Docs/BuildFFUVM_flowchart.md | 8 +-
.../FFU.Common/FFU.Common.Cleanup.psm1 | 13 +-
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 16 +-
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 19 +-
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 16 -
FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 5 -
.../AssignDriveLetter.txt | 4 -
.../WinPECaptureFFUFiles/CaptureFFU.ps1 | 237 ---------
.../Windows/System32/startnet.cmd | 5 -
FFUDevelopment/config/Sample_default.json | Bin 6412 -> 6094 bytes
docs/appsscriptvariables.md | 5 -
docs/build.md | 48 +-
docs/create_pemedia.md | 14 +-
docs/hyperv_settings.md | 6 +-
docs/parameters_reference.md | 7 +-
docs/quickstart.md | 2 +-
19 files changed, 170 insertions(+), 811 deletions(-)
delete mode 100644 FFUDevelopment/WinPECaptureFFUFiles/AssignDriveLetter.txt
delete mode 100644 FFUDevelopment/WinPECaptureFFUFiles/CaptureFFU.ps1
delete mode 100644 FFUDevelopment/WinPECaptureFFUFiles/Windows/System32/startnet.cmd
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index df4907e..4eeb9f1 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -36,9 +36,6 @@ Switch to run cleanup-only mode. When specified, the script performs cleanup and
.PARAMETER CleanupAppsISO
When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true.
-.PARAMETER CleanupCaptureISO
-When set to $true, will remove the WinPE capture ISO after the FFU has been captured. Default is $true.
-
.PARAMETER CleanupCurrentRunDownloads
When set to $true, cleanup mode will remove downloads created during the current run and restore backed up run JSON files. Default is $false.
@@ -75,9 +72,6 @@ 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 CreateCaptureMedia
-When set to $true, this will create WinPE capture media for use when $InstallApps is set to $true. This capture media will be automatically attached to the VM, and the boot order will be changed to automate the capture of the FFU.
-
.PARAMETER CreateDeploymentMedia
When set to $true, this will create WinPE deployment media for use when deploying to a physical device.
@@ -177,9 +171,6 @@ When set to $true, will remove the downloaded CU, MSRT, Defender, Edge, OneDrive
.PARAMETER RemoveDownloadedESD
When set to $true, will remove downloaded Windows ESD files after they have been applied. Default is $true.
-.PARAMETER ShareName
-Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed.
-
.PARAMETER Threads
Controls the throttle applied to parallel tasks inside the script. Default is 5, matching the UI Threads field, and applies to driver downloads invoked through Invoke-ParallelProcessing.
@@ -228,17 +219,11 @@ User agent string to use when downloading files.
.PARAMETER UserAppListPath
Path to a JSON file containing a list of user-defined applications to install. Default is $FFUDevelopmentPath\Apps\UserAppList.json.
-.PARAMETER Username
-Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account.
-
-.PARAMETER VMHostIPAddress
-IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this, or use the UI to auto-detect.
-
.PARAMETER VMLocation
Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to.
.PARAMETER VMSwitchName
-Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set to capture the FFU from the VM.
+Name of the Hyper-V virtual switch. Optional when building with InstallApps. Provide it only if the VM needs network connectivity during provisioning.
.PARAMETER WindowsArch
String value of 'x86', 'x64', or 'arm64'. This is used to identify which architecture of Windows to download. Default is 'x64'.
@@ -257,25 +242,25 @@ String value of the Windows version to download. This is used to identify which
.EXAMPLE
Command line for most people who want to download the latest Windows 11 Pro x64 media in English (US) with the latest Windows Cumulative Update, .NET Framework, Defender platform and definition updates, Edge, OneDrive, and Office/M365 Apps. It will also copy drivers to the FFU. This can take about 40 minutes to create the FFU due to the time it takes to download and install the updates.
-.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -verbose
+.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -verbose
Command line for most people who want to create an FFU with Office and drivers and have downloaded their own ISO. This assumes you have copied this script and associated files to the C:\FFUDevelopment folder. If you need to use another drive or folder, change the -FFUDevelopment parameter (e.g. -FFUDevelopment 'D:\FFUDevelopment')
-.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
+.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
Command line for those who just want a FFU with no drivers, apps, or Office and have downloaded their own ISO.
-.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $false -InstallOffice $false -InstallDrivers $false -CreateCaptureMedia $false -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
+.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $false -InstallOffice $false -InstallDrivers $false -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
Command line for those who just want a FFU with Apps and drivers, no Office and have downloaded their own ISO.
-.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $false -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
+.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $false -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
Command line for those who want to download the latest Windows 11 Pro x64 media in English (US) and install the latest version of Office and drivers.
-.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
+.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
Command line for those who want to download the latest Windows 11 Pro x64 media in French (CA) and install the latest version of Office and drivers.
-.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose
+.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose
Command line for those who want to download the latest Windows 11 Pro x64 media in English (US) and install the latest version of Office and drivers.
-.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
+.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose
.NOTES
Additional notes about your script.
@@ -336,12 +321,7 @@ param(
[string]$VMLocation,
[string]$FFUPrefix = '_FFU',
[string]$FFUCaptureLocation,
- [string]$ShareName = "FFUCaptureShare",
- [string]$Username = "ffu_user",
[string]$CustomFFUNameTemplate,
- [Parameter(Mandatory = $false)]
- [string]$VMHostIPAddress,
- [bool]$CreateCaptureMedia = $true,
[bool]$CreateDeploymentMedia,
[ValidateScript({
$allowedFeatures = @("Windows-Defender-Default-Definitions", "Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "TelnetClient", "TFTP",
@@ -425,7 +405,6 @@ param(
[bool]$CopyUnattend,
[bool]$CopyAutopilot,
[bool]$CompactOS = $true,
- [bool]$CleanupCaptureISO = $true,
[bool]$CleanupDeployISO = $true,
[bool]$CleanupAppsISO = $true,
[bool]$RemoveUpdates = $true,
@@ -647,7 +626,6 @@ if (-not $LtscCUStagePath) { $LtscCUStagePath = "$AppsPath\LTSCUpdate" }
if (-not $AppsScriptVarsJsonPath) { $AppsScriptVarsJsonPath = "$OrchestrationPath\AppsScriptVariables.json" }
if (-not $DeployISO) { $DeployISO = "$FFUDevelopmentPath\WinPE_FFU_Deploy_$WindowsArch.iso" }
-if (-not $CaptureISO) { $CaptureISO = "$FFUDevelopmentPath\WinPE_FFU_Capture_$WindowsArch.iso" }
if (-not $OfficePath) { $OfficePath = "$AppsPath\Office" }
if (-not $OfficeDownloadXML) { $OfficeDownloadXML = "$OfficePath\DownloadFFU.xml" }
if (-not $OfficeInstallXML) { $OfficeInstallXML = "DeployFFU.xml" }
@@ -2848,69 +2826,6 @@ function New-FFUVM {
return $VM
}
-Function Set-CaptureFFU {
- $CaptureFFUScriptPath = "$FFUDevelopmentPath\WinPECaptureFFUFiles\CaptureFFU.ps1"
-
- # Workaround for PowerShell 7 issue on Windows 11 23H2 and earlier
- # https://github.com/PowerShell/PowerShell/issues/21645
- $osBuild = (Get-CimInstance -ClassName Win32_OperatingSystem).BuildNumber
- if ($osBuild -le 22631) {
- WriteLog "Applying workaround for PowerShell 7 LocalAccounts module issue on Windows 11 build $osBuild"
- Import-Module Microsoft.PowerShell.LocalAccounts -UseWindowsPowerShell
- }
-
- If (-not (Test-Path -Path $FFUCaptureLocation)) {
- WriteLog "Creating FFU capture location at $FFUCaptureLocation"
- New-Item -Path $FFUCaptureLocation -ItemType Directory -Force
- WriteLog "Successfully created FFU capture location at $FFUCaptureLocation"
- }
-
- # Create a standard user
- $UserExists = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue
- if (-not $UserExists) {
- WriteLog "Creating FFU_User account as standard user"
- New-LocalUser -Name $UserName -AccountNeverExpires -NoPassword | Out-null
- WriteLog "Successfully created FFU_User account"
- }
-
- # Create a random password for the standard user
- $Password = New-Guid | Select-Object -ExpandProperty Guid
- $SecurePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force
- Set-LocalUser -Name $UserName -Password $SecurePassword -PasswordNeverExpires:$true
-
- # Create a share of the $FFUCaptureLocation variable
- $ShareExists = Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue
- if (-not $ShareExists) {
- WriteLog "Creating $ShareName and giving access to $UserName"
- New-SmbShare -Name $ShareName -Path $FFUCaptureLocation -FullAccess $UserName | Out-Null
- WriteLog "Share created"
- }
-
- # Return the share path in the format of \\\ /user:
- $SharePath = "\\$VMHostIPAddress\$ShareName /user:$UserName $Password"
- $SharePath = "net use W: " + $SharePath + ' 2>&1'
-
- # Update CaptureFFU.ps1 script
- if (Test-Path -Path $CaptureFFUScriptPath) {
- $ScriptContent = Get-Content -Path $CaptureFFUScriptPath
- #Update variables in CaptureFFU.ps1 script ($VMHostIPAddress, $ShareName, $UserName, $Password)
- WriteLog 'Updating CaptureFFU.ps1 script with new share information'
- $ScriptContent = $ScriptContent -replace '(\$VMHostIPAddress = ).*', "`$1'$VMHostIPAddress'"
- $ScriptContent = $ScriptContent -replace '(\$ShareName = ).*', "`$1'$ShareName'"
- $ScriptContent = $ScriptContent -replace '(\$UserName = ).*', "`$1'$UserName'"
- $ScriptContent = $ScriptContent -replace '(\$Password = ).*', "`$1'$Password'"
- if (![string]::IsNullOrEmpty($CustomFFUNameTemplate)) {
- $ScriptContent = $ScriptContent -replace '(\$CustomFFUNameTemplate = ).*', "`$1'$CustomFFUNameTemplate'"
- WriteLog 'Updating CaptureFFU.ps1 script with new ffu name template information'
- }
- Set-Content -Path $CaptureFFUScriptPath -Value $ScriptContent
- WriteLog 'Update complete'
- }
- else {
- throw "CaptureFFU.ps1 script not found at $CaptureFFUScriptPath"
- }
-}
-
function Get-PrivateProfileString {
param (
[Parameter()]
@@ -3371,12 +3286,7 @@ function Copy-Drivers {
}
function New-PEMedia {
- param (
- [Parameter()]
- [bool]$Capture,
- [Parameter()]
- [bool]$Deploy
- )
+ param ()
#Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -3431,64 +3341,49 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete"
}
- If ($Capture) {
- WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
- Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
- WriteLog "Copy complete"
- #Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
- #Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
- # $WinPEISOName = 'WinPE_FFU_Capture.iso'
- $WinPEISOFile = $CaptureISO
- # $Capture = $false
- }
- If ($Deploy) {
- WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
- Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
- WriteLog 'Copy complete'
- #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
- if ($CopyPEDrivers) {
- if ($UseDriversAsPEDrivers) {
- WriteLog "UseDriversAsPEDrivers is set. Building WinPE driver set from Drivers folder (bypassing PEDrivers folder contents)."
- if (Test-Path -Path $PEDriversFolder) {
- try {
- Remove-Item -Path (Join-Path $PEDriversFolder '*') -Recurse -Force -ErrorAction SilentlyContinue | Out-Null
- }
- catch {
- WriteLog "Warning: Failed clearing existing PEDriversFolder contents: $($_.Exception.Message)"
- }
+ WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
+ Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
+ WriteLog 'Copy complete'
+ #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
+ if ($CopyPEDrivers) {
+ if ($UseDriversAsPEDrivers) {
+ WriteLog "UseDriversAsPEDrivers is set. Building WinPE driver set from Drivers folder (bypassing PEDrivers folder contents)."
+ if (Test-Path -Path $PEDriversFolder) {
+ try {
+ Remove-Item -Path (Join-Path $PEDriversFolder '*') -Recurse -Force -ErrorAction SilentlyContinue | Out-Null
}
- else {
- try {
- New-Item -Path $PEDriversFolder -ItemType Directory -Force | Out-Null
- }
- catch {
- WriteLog "Error: Failed to create PEDriversFolder at $PEDriversFolder - continuing may fail when adding drivers."
- }
+ catch {
+ WriteLog "Warning: Failed clearing existing PEDriversFolder contents: $($_.Exception.Message)"
}
- WriteLog "Copying required WinPE drivers from Drivers folder"
- Copy-Drivers -Path $DriversFolder -Output $PEDriversFolder
}
else {
- WriteLog "Copying PE drivers from PEDrivers folder"
+ try {
+ New-Item -Path $PEDriversFolder -ItemType Directory -Force | Out-Null
+ }
+ catch {
+ WriteLog "Error: Failed to create PEDriversFolder at $PEDriversFolder - continuing may fail when adding drivers."
+ }
}
-
- WriteLog "Adding drivers to WinPE media"
- try {
- $WinPEMount = "$WinPEFFUPath\Mount"
-
- # Inject drivers using deep SUBST mapping (reuse one drive letter and loop each INF folder)
- Invoke-DismDriverInjectionWithSubstLoop -ImagePath $WinPEMount -DriverRoot $PEDriversFolder
- }
- catch {
- WriteLog 'Some drivers failed to be added. This can be expected. Continuing.'
- }
- WriteLog "Adding drivers complete"
+ WriteLog "Copying required WinPE drivers from Drivers folder"
+ Copy-Drivers -Path $DriversFolder -Output $PEDriversFolder
}
- # $WinPEISOName = 'WinPE_FFU_Deploy.iso'
- $WinPEISOFile = $DeployISO
+ else {
+ WriteLog "Copying PE drivers from PEDrivers folder"
+ }
+
+ WriteLog "Adding drivers to WinPE media"
+ try {
+ $WinPEMount = "$WinPEFFUPath\Mount"
- # $Deploy = $false
+ # Inject drivers using deep SUBST mapping (reuse one drive letter and loop each INF folder)
+ Invoke-DismDriverInjectionWithSubstLoop -ImagePath $WinPEMount -DriverRoot $PEDriversFolder
+ }
+ catch {
+ WriteLog 'Some drivers failed to be added. This can be expected. Continuing.'
+ }
+ WriteLog "Adding drivers complete"
}
+ $WinPEISOFile = $DeployISO
WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete'
@@ -3503,21 +3398,10 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile"
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
if ($WindowsArch -eq 'x64') {
- if ($Capture) {
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
- if ($Deploy) {
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
+ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
elseif ($WindowsArch -eq 'arm64') {
- if ($Capture) {
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
- if ($Deploy) {
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
-
+ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
Invoke-Process $OSCDIMG $OSCDIMGArgs | Out-Null
WriteLog "ISO created successfully"
@@ -3525,7 +3409,7 @@ function New-PEMedia {
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete'
# Deferred cleanup of preserved driver model folders (only after WinPE Deploy media is created)
- if ($UseDriversAsPEDrivers -and $CompressDownloadedDriversToWim -and $Deploy -and $CopyPEDrivers) {
+ if ($UseDriversAsPEDrivers -and $CompressDownloadedDriversToWim -and $CopyPEDrivers) {
WriteLog "Beginning deferred cleanup of preserved driver model folders (UseDriversAsPEDrivers + compression scenario)."
$removedCount = 0
$skippedCount = 0
@@ -3616,6 +3500,40 @@ function Optimize-FFUCaptureDrive {
}
}
+function Get-CaptureVhdContext {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$VhdxPath
+ )
+
+ WriteLog 'Resolving VHDX context for host-side FFU capture'
+
+ $vhdInfo = Get-VHD -Path $VhdxPath
+ if ($vhdInfo.Attached) {
+ WriteLog 'VHDX is already mounted for capture'
+ $captureDisk = Get-Disk -Number $vhdInfo.DiskNumber
+ }
+ else {
+ WriteLog 'Mounting VHDX for capture'
+ $captureDisk = Mount-VHD -Path $VhdxPath -Passthru | Get-Disk
+ }
+
+ $captureOsPartition = $captureDisk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' } | Select-Object -First 1
+ if ($null -eq $captureOsPartition) {
+ throw 'Unable to resolve Windows partition for FFU capture.'
+ }
+ if ([string]::IsNullOrWhiteSpace($captureOsPartition.DriveLetter)) {
+ throw 'Unable to resolve Windows partition drive letter for FFU capture.'
+ }
+
+ return [pscustomobject]@{
+ Disk = $captureDisk
+ OsPartition = $captureOsPartition
+ OsPartitionDriveLetter = $captureOsPartition.DriveLetter
+ WindowsPartition = "$($captureOsPartition.DriveLetter):\"
+ }
+}
+
function Get-ShortenedWindowsSKU {
param (
[string]$WindowsSKU
@@ -3703,89 +3621,53 @@ function New-FFUFileName {
}
function New-FFU {
- param (
- [Parameter(Mandatory = $false)]
- [string]$VMName
- )
- #If $InstallApps = $true, configure the VM
- If ($InstallApps) {
- WriteLog 'Creating FFU from VM'
- WriteLog "Setting $CaptureISO as first boot device"
- $VMDVDDrive = Get-VMDvdDrive -VMName $VMName
- Set-VMFirmware -VMName $VMName -FirstBootDevice $VMDVDDrive
- Set-VMDvdDrive -VMName $VMName -Path $CaptureISO
- $VMSwitch = Get-VMSwitch -name $VMSwitchName
- WriteLog "Setting $($VMSwitch.Name) as VMSwitch"
- get-vm $VMName | Get-VMNetworkAdapter | Connect-VMNetworkAdapter -SwitchName $VMSwitch.Name
- WriteLog "Configuring VM complete"
+ $captureContext = Get-CaptureVhdContext -VhdxPath $VHDXPath
+ $captureDisk = $captureContext.Disk
+ $osPartitionDriveLetter = $captureContext.OsPartitionDriveLetter
+ $WindowsPartition = $captureContext.WindowsPartition
- #Start VM
- Set-Progress -Percentage 68 -Message "Capturing FFU from VM..."
- WriteLog "Starting VM"
- Start-VM -Name $VMName
-
- # Wait for the VM to turn off
- do {
- $FFUVM = Get-VM -Name $VMName
- Start-Sleep -Seconds 5
- } while ($FFUVM.State -ne 'Off')
- WriteLog "VM Shutdown"
- # Check for .ffu files in the FFUDevelopment folder
- WriteLog "Checking for FFU Files"
- $FFUFiles = Get-ChildItem -Path $FFUCaptureLocation -Filter "*.ffu" -File
-
- # If there's more than one .ffu file, get the most recent and store its path in $FFUFile
- if ($FFUFiles.Count -gt 0) {
- WriteLog 'Getting the most recent FFU file'
- $FFUFile = ($FFUFiles | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1).FullName
- WriteLog "Most recent .ffu file: $FFUFile"
- }
- else {
- WriteLog "No .ffu files found in $FFUCaptureLocation"
- throw $_
- }
- }
- elseif (-not $InstallApps -and (-not $AllowVHDXCaching)) {
- #Get Windows Version Information from the VHDX
- $winverinfo = Get-WindowsVersionInfo
- WriteLog 'Creating FFU File Name'
- if ($CustomFFUNameTemplate) {
- $FFUFileName = New-FFUFileName
- }
- else {
- $FFUFileName = "$($winverinfo.Name)`_$($winverinfo.DisplayVersion)`_$($shortenedWindowsSKU)`_$($winverinfo.BuildDate).ffu"
- }
- WriteLog "FFU file name: $FFUFileName"
- $FFUFile = "$FFUCaptureLocation\$FFUFileName"
- #Capture the FFU
+ try {
Set-Progress -Percentage 68 -Message "Capturing FFU from VHDX..."
- WriteLog 'Capturing FFU'
- Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
- WriteLog 'FFU Capture complete'
- Dismount-ScratchVhdx -VhdxPath $VHDXPath
- }
- elseif (-not $InstallApps -and $AllowVHDXCaching) {
- # Make $FFUFileName based on values in the config.json file
- WriteLog 'Creating FFU File Name'
- if ($CustomFFUNameTemplate) {
- $FFUFileName = New-FFUFileName
- }
- else {
- $BuildDate = Get-Date -UFormat %b%Y
- # Get Windows Information to make the FFU file name from the cachedVHDXInfo file
- if ($installationType -eq 'Client') {
- $FFUFileName = "Win$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
+
+ if ($InstallApps -or (-not $AllowVHDXCaching)) {
+ # Resolve live Windows metadata from the mounted VHDX when the image was customized in a VM.
+ $winverinfo = Get-WindowsVersionInfo
+ WriteLog 'Creating FFU File Name'
+ if ($CustomFFUNameTemplate) {
+ $FFUFileName = New-FFUFileName
}
else {
- $FFUFileName = "Server$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
- }
+ $FFUFileName = "$($winverinfo.Name)`_$($winverinfo.DisplayVersion)`_$($shortenedWindowsSKU)`_$($winverinfo.BuildDate).ffu"
+ }
+ WriteLog "FFU file name: $FFUFileName"
+ $FFUFile = "$FFUCaptureLocation\$FFUFileName"
+ WriteLog 'Capturing FFU from mounted VHDX on host'
+ Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($captureDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
}
- WriteLog "FFU file name: $FFUFileName"
- $FFUFile = "$FFUCaptureLocation\$FFUFileName"
- #Capture the FFU
- WriteLog 'Capturing FFU'
- Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($cachedVHDXInfo.WindowsRelease)$($cachedVHDXInfo.WindowsVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
+ else {
+ # Use cached Windows metadata only when the VHDX contents were reused without VM customization.
+ WriteLog 'Creating FFU File Name'
+ if ($CustomFFUNameTemplate) {
+ $FFUFileName = New-FFUFileName
+ }
+ else {
+ $BuildDate = Get-Date -UFormat %b%Y
+ if ($installationType -eq 'Client') {
+ $FFUFileName = "Win$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
+ }
+ else {
+ $FFUFileName = "Server$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
+ }
+ }
+ WriteLog "FFU file name: $FFUFileName"
+ $FFUFile = "$FFUCaptureLocation\$FFUFileName"
+ WriteLog 'Capturing FFU from mounted VHDX on host'
+ Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($captureDisk.DiskNumber) /Name:$($cachedVHDXInfo.WindowsRelease)$($cachedVHDXInfo.WindowsVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
+ }
+
WriteLog 'FFU Capture complete'
+ }
+ finally {
Dismount-ScratchVhdx -VhdxPath $VHDXPath
}
@@ -3893,15 +3775,6 @@ function Remove-FFUVM {
Invoke-Process cmd "/c mountvol /r" | Out-Null
WriteLog 'Removal complete'
}
-Function Remove-FFUUserShare {
- WriteLog "Removing $ShareName"
- Remove-SmbShare -Name $ShareName -Force | Out-null
- WriteLog 'Removal complete'
- WriteLog "Removing $Username"
- Remove-LocalUser -Name $Username | Out-Null
- WriteLog 'Removal complete'
-}
-
Function Get-WindowsVersionInfo {
#This sleep prevents CBS/CSI corruption which causes issues with Windows update after deployment. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. This seems to affect VHDX-only captures, not VM captures.
WriteLog 'Sleep 60 seconds before opening registry to grab Windows version info '
@@ -4441,16 +4314,8 @@ function Get-FFUEnvironment {
Invoke-Process reg "unload HKLM\FFU" | Out-Null
}
- #Remove FFU User and Share
- $UserExists = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue
- if ($UserExists) {
- WriteLog "Removing FFU User and Share"
- Remove-FFUUserShare
- WriteLog 'Removal complete'
- }
-
#Run shared cleanup to avoid duplicated logic
- Invoke-FFUPostBuildCleanup -RootPath $FFUDevelopmentPath -AppsPath $AppsPath -DriversPath $DriversFolder -FFUCapturePath $FFUCaptureLocation -CaptureISOPath $CaptureISO -DeployISOPath $DeployISO -AppsISOPath $AppsISO -RemoveCaptureISO:$CleanupCaptureISO -RemoveDeployISO:$CleanupDeployISO -RemoveAppsISO:$CleanupAppsISO -RemoveDrivers:$CleanupDrivers -RemoveFFU:$RemoveFFU -RemoveApps:$RemoveApps -RemoveUpdates:$RemoveUpdates -RemoveDownloadedESD:$RemoveDownloadedESD -KBPath:$KBPath
+ Invoke-FFUPostBuildCleanup -RootPath $FFUDevelopmentPath -AppsPath $AppsPath -DriversPath $DriversFolder -FFUCapturePath $FFUCaptureLocation -DeployISOPath $DeployISO -AppsISOPath $AppsISO -RemoveDeployISO:$CleanupDeployISO -RemoveAppsISO:$CleanupAppsISO -RemoveDrivers:$CleanupDrivers -RemoveFFU:$RemoveFFU -RemoveApps:$RemoveApps -RemoveUpdates:$RemoveUpdates -RemoveDownloadedESD:$RemoveDownloadedESD -KBPath:$KBPath
# Remove existing Apps.iso
if (Test-Path -Path $AppsISO) {
@@ -5576,14 +5441,6 @@ if ($CopyUnattend) {
WriteLog 'Unattend validation complete'
}
-# If InstallApps is true, we need capture media.
-if ($InstallApps) {
- if (-not $CreateCaptureMedia) {
- WriteLog "InstallApps is true, but CreateCaptureMedia is false. Forcing to true to allow for VM capture to FFU."
- $CreateCaptureMedia = $true
- }
-}
-
#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.
@@ -5595,45 +5452,14 @@ if ($InstallApps) {
if (($InstallOffice -eq $true) -and ($InstallApps -eq $false)) {
throw "If variable InstallOffice is set to `$true, InstallApps must also be set to `$true."
}
-if (($InstallApps -and ($VMSwitchName -eq ''))) {
- throw "If variable InstallApps is set to `$true, VMSwitchName must also be set to capture the FFU. Please set -VMSwitchName and try again."
-}
-
-if (($InstallApps -and ($VMHostIPAddress -eq ''))) {
- throw "If variable InstallApps is set to `$true, VMHostIPAddress must also be set to capture the FFU. Please set -VMHostIPAddress and try again."
-}
-
-if (($VMHostIPAddress) -and ($VMSwitchName)) {
- WriteLog "Validating -VMSwitchName $VMSwitchName and -VMHostIPAddress $VMHostIPAddress"
+if ($VMSwitchName) {
+ WriteLog "Validating -VMSwitchName $VMSwitchName"
#Check $VMSwitchName by using Get-VMSwitch
$VMSwitch = Get-VMSwitch -Name $VMSwitchName -ErrorAction SilentlyContinue
if (-not $VMSwitch) {
throw "-VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again."
}
- #Find the IP address of $VMSwitch and check if it matches $VMHostIPAddress
- $interfaceAlias = "vEthernet ($VMSwitchName)"
- $VMSwitchIPAddress = (Get-NetIPAddress -InterfaceAlias $interfaceAlias -AddressFamily 'IPv4' -ErrorAction SilentlyContinue).IPAddress
- if (-not $VMSwitchIPAddress) {
- throw "IP address for -VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again."
- }
- if ($VMSwitchIPAddress -ne $VMHostIPAddress) {
- try {
- # Bypass the check for systems that could have a Hyper-V NAT switch
- $null = Get-NetNat -ErrorAction Stop
- $NetNat = @(Get-NetNat -ErrorAction Stop)
- }
- catch {
- throw "IP address for -VMSwitchName $VMSwitchName is $VMSwitchIPAddress, which does not match the -VMHostIPAddress $VMHostIPAddress. Please check the -VMHostIPAddress parameter and try again."
- }
- if ($NetNat.Count -gt 0) {
- WriteLog "IP address for -VMSwitchName $VMSwitchName is $VMSwitchIPAddress, which does not match the -VMHostIPAddress $VMHostIPAddress!"
- WriteLog "NAT setup detected, remember to configure NATing if the FFU image can't be captured to the network share on the host."
- }
- else {
- throw "IP address for -VMSwitchName $VMSwitchName is $VMSwitchIPAddress, which does not match the -VMHostIPAddress $VMHostIPAddress. Please check the -VMHostIPAddress parameter and try again."
- }
- }
- WriteLog '-VMSwitchName and -VMHostIPAddress validation complete'
+ WriteLog '-VMSwitchName validation complete'
}
if (-not ($ISOPath) -and ($OptionalFeatures -like '*netfx3*')) {
@@ -7455,32 +7281,6 @@ if ($InstallApps) {
throw $_
}
- #Create ffu user and share to capture FFU to
- try {
- Set-CaptureFFU
- }
- catch {
- Write-Host 'Set-CaptureFFU function failed'
- WriteLog "Set-CaptureFFU function failed with error $_"
- Remove-FFUVM -VMName $VMName
- throw $_
-
- }
- If ($CreateCaptureMedia) {
- #Create Capture Media
- try {
- Set-Progress -Percentage 45 -Message "Creating WinPE capture media..."
- #This should happen while the FFUVM is building
- New-PEMedia -Capture $true
- }
- catch {
- Write-Host 'Creating capture media failed'
- WriteLog "Creating capture media failed with error $_"
- Remove-FFUVM -VMName $VMName
- throw $_
-
- }
- }
}
#Capture FFU file
try {
@@ -7490,6 +7290,10 @@ try {
New-Item -Path $FFUCaptureLocation -ItemType Directory -Force
WriteLog "Successfully created FFU capture location at $FFUCaptureLocation"
}
+ #Shorten Windows SKU for use in FFU file name to remove spaces and long names
+ WriteLog "Shortening Windows SKU: $WindowsSKU for FFU file name"
+ $shortenedWindowsSKU = Get-ShortenedWindowsSKU -WindowsSKU $WindowsSKU
+ WriteLog "Shortened Windows SKU: $shortenedWindowsSKU"
#Check if VM is done provisioning
If ($InstallApps) {
Set-Progress -Percentage 50 -Message "Installing applications in VM; please wait for VM to shut down..."
@@ -7502,13 +7306,9 @@ try {
Set-Progress -Percentage 65 -Message "Optimizing VHDX before capture..."
Optimize-FFUCaptureDrive -VhdxPath $VHDXPath
#Capture FFU file
- New-FFU $FFUVM.Name
+ New-FFU
}
else {
- #Shorten Windows SKU for use in FFU file name to remove spaces and long names
- WriteLog "Shortening Windows SKU: $WindowsSKU for FFU file name"
- $shortenedWindowsSKU = Get-ShortenedWindowsSKU -WindowsSKU $WindowsSKU
- WriteLog "Shortened Windows SKU: $shortenedWindowsSKU"
#Create FFU file
New-FFU
}
@@ -7526,18 +7326,6 @@ Catch {
throw $_
}
-#Clean up ffu_user and Share and clean up apps
-If ($InstallApps) {
- try {
- Remove-FFUUserShare
- }
- catch {
- Write-Host 'Cleaning up FFU User and/or share failed'
- WriteLog "Cleaning up FFU User and/or share failed with error $_"
- Remove-FFUVM -VMName $VMName
- throw $_
- }
-}
#Clean up VM or VHDX
try {
Remove-FFUVM
@@ -7554,7 +7342,7 @@ catch {
If ($CreateDeploymentMedia) {
Set-Progress -Percentage 91 -Message "Creating deployment media..."
try {
- New-PEMedia -Deploy $true
+ New-PEMedia
}
catch {
Write-Host 'Creating deployment media failed'
@@ -7611,7 +7399,7 @@ If ($BuildUSBDrive) {
Set-Progress -Percentage 99 -Message "Finalizing and cleaning up..."
# Delegated post-build cleanup to common module
-Invoke-FFUPostBuildCleanup -RootPath $FFUDevelopmentPath -AppsPath $AppsPath -DriversPath $DriversFolder -FFUCapturePath $FFUCaptureLocation -CaptureISOPath $CaptureISO -DeployISOPath $DeployISO -AppsISOPath $AppsISO -RemoveCaptureISO:$CleanupCaptureISO -RemoveDeployISO:$CleanupDeployISO -RemoveAppsISO:$CleanupAppsISO -RemoveDrivers:$CleanupDrivers -RemoveFFU:$RemoveFFU -RemoveApps:$RemoveApps -RemoveUpdates:$RemoveUpdates -RemoveDownloadedESD:$RemoveDownloadedESD -KBPath:$KBPath
+Invoke-FFUPostBuildCleanup -RootPath $FFUDevelopmentPath -AppsPath $AppsPath -DriversPath $DriversFolder -FFUCapturePath $FFUCaptureLocation -DeployISOPath $DeployISO -AppsISOPath $AppsISO -RemoveDeployISO:$CleanupDeployISO -RemoveAppsISO:$CleanupAppsISO -RemoveDrivers:$CleanupDrivers -RemoveFFU:$RemoveFFU -RemoveApps:$RemoveApps -RemoveUpdates:$RemoveUpdates -RemoveDownloadedESD:$RemoveDownloadedESD -KBPath:$KBPath
# Remove WinGetWin32Apps.json so it is always rebuilt next run
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 4c0c842..f645932 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -320,9 +320,6 @@
-
-
-
@@ -823,13 +820,13 @@
-
+
-
+
-
+
-
+
@@ -874,16 +871,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -906,7 +893,6 @@
-
@@ -983,7 +969,6 @@
-
diff --git a/FFUDevelopment/Create-PEMedia.ps1 b/FFUDevelopment/Create-PEMedia.ps1
index 2b641af..3ebbeee 100644
--- a/FFUDevelopment/Create-PEMedia.ps1
+++ b/FFUDevelopment/Create-PEMedia.ps1
@@ -3,11 +3,8 @@ param (
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
[string]$WindowsArch = 'x64',
[bool]$CopyPEDrivers = $false,
- [string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
- [string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
- [bool]$Capture,
- [bool]$Deploy = $true
+ [string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
)
function WriteLog($LogText) {
@@ -77,12 +74,7 @@ function Invoke-Process {
}
function New-PEMedia {
- param (
- [Parameter()]
- [bool]$Capture,
- [Parameter()]
- [bool]$Deploy
- )
+ param ()
#Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -135,36 +127,21 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete"
}
- If ($Capture) {
- WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
- Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
- WriteLog "Copy complete"
- #Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
- #Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
- # $WinPEISOName = 'WinPE_FFU_Capture.iso'
- $WinPEISOFile = $CaptureISO
- # $Capture = $false
- }
- If ($Deploy) {
- WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
- Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
- WriteLog 'Copy complete'
- #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
- if ($CopyPEDrivers) {
- WriteLog "Adding drivers to WinPE media"
- try {
- Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
- }
- catch {
- WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
- }
- WriteLog "Adding drivers complete"
+ WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
+ Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
+ WriteLog 'Copy complete'
+ #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
+ if ($CopyPEDrivers) {
+ WriteLog "Adding drivers to WinPE media"
+ try {
+ Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
}
- # $WinPEISOName = 'WinPE_FFU_Deploy.iso'
- $WinPEISOFile = $DeployISO
-
- # $Deploy = $false
+ catch {
+ WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
+ }
+ WriteLog "Adding drivers complete"
}
+ $WinPEISOFile = $DeployISO
WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete'
@@ -179,21 +156,10 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile"
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
if($WindowsArch -eq 'x64'){
- if($Capture){
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
- if($Deploy){
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
+ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
elseif($WindowsArch -eq 'arm64'){
- if($Capture){
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
- if($Deploy){
- $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
- }
-
+ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
Invoke-Process $OSCDIMG $OSCDIMGArgs
WriteLog "ISO created successfully"
@@ -201,9 +167,4 @@ function New-PEMedia {
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete'
}
-if($Capture){
- New-PEMedia -Capture $Capture
-}
-if($Deploy){
- New-PEMedia -Deploy $Deploy
-}
\ No newline at end of file
+New-PEMedia
\ No newline at end of file
diff --git a/FFUDevelopment/Docs/BuildFFUVM_flowchart.md b/FFUDevelopment/Docs/BuildFFUVM_flowchart.md
index e4d1691..6fd44fd 100644
--- a/FFUDevelopment/Docs/BuildFFUVM_flowchart.md
+++ b/FFUDevelopment/Docs/BuildFFUVM_flowchart.md
@@ -85,12 +85,10 @@ graph TD
subgraph "VM-Based Capture (-InstallApps)"
direction LR
BB[Create Hyper-V VM from VHDX];
- BB --> BC["Create WinPE Capture Media iso"];
- BC --> BD[Configure network share for capture];
- BD --> BE["Start VM: Boots to Audit Mode"];
+ BB --> BE["Start VM: Boots to Audit Mode"];
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
- BF --> BG[VM reboots from Capture Media];
- BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
+ BF --> BG[Host optimizes and remounts VHDX];
+ BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
end
subgraph "Direct VHDX Capture"
diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Cleanup.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Cleanup.psm1
index 2a28a14..9afea0b 100644
--- a/FFUDevelopment/FFU.Common/FFU.Common.Cleanup.psm1
+++ b/FFUDevelopment/FFU.Common/FFU.Common.Cleanup.psm1
@@ -6,11 +6,9 @@ function Invoke-FFUPostBuildCleanup {
[string]$AppsPath,
[string]$DriversPath,
[string]$FFUCapturePath,
- [string]$CaptureISOPath,
[string]$DeployISOPath,
[string]$AppsISOPath,
[string]$KBPath,
- [bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false,
@@ -22,13 +20,9 @@ function Invoke-FFUPostBuildCleanup {
$originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
try {
- WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
+ WriteLog "CommonCleanup: Starting cleanup (DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
# Primary ISO paths (new naming/location)
- if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
- WriteLog "CommonCleanup: Removing $CaptureISOPath"
- try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
- }
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
WriteLog "CommonCleanup: Removing $DeployISOPath"
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
@@ -39,11 +33,6 @@ function Invoke-FFUPostBuildCleanup {
}
# Legacy / root-level WinPE ISOs (pattern-based)
- if ($RemoveCaptureISO) {
- Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
- try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
- }
- }
if ($RemoveDeployISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index 0ba666f..982b5c5 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -25,7 +25,6 @@ function Get-UIConfig {
else { $null }
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
- CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
CompactOS = $State.Controls.chkCompactOS.IsChecked
@@ -38,7 +37,6 @@ function Get-UIConfig {
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
- CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
@@ -84,7 +82,6 @@ function Get-UIConfig {
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
- ShareName = $State.Controls.txtShareName.Text
UpdateADK = $State.Controls.chkUpdateADK.IsChecked
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
@@ -96,13 +93,11 @@ function Get-UIConfig {
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
UserAppListPath = $State.Controls.txtUserAppListPath.Text
USBDriveList = @{}
- Username = $State.Controls.txtUsername.Text
Threads = [int]$State.Controls.txtThreads.Text
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
Verbose = $State.Controls.chkVerbose.IsChecked
- VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
VMLocation = $State.Controls.txtVMLocation.Text
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Text
@@ -414,7 +409,6 @@ function Select-VMSwitchFromConfig {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
$State.Data.customVMSwitchName = $configSwitch
- $State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
}
}
@@ -442,8 +436,6 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
- Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
- Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
@@ -455,7 +447,6 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
- Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
@@ -467,7 +458,6 @@ function Update-UIFromConfig {
# Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
- Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
@@ -477,7 +467,6 @@ function Update-UIFromConfig {
# Hyper-V Settings
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
- Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
@@ -884,11 +873,10 @@ function Invoke-RestoreDefaults {
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
- $captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
$appsISOPath = Join-Path $rootPath 'Apps.iso'
- $msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Capture, Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
+ $msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
WriteLog "RestoreDefaults: User cancelled."
@@ -924,11 +912,9 @@ function Invoke-RestoreDefaults {
-AppsPath $appsPath `
-DriversPath $driversPath `
-FFUCapturePath $ffuCapturePath `
- -CaptureISOPath $captureISOPath `
-DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') `
- -RemoveCaptureISO:$true `
-RemoveDeployISO:$true `
-RemoveAppsISO:$true `
-RemoveDrivers:$true `
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 3d67c22..ef7c5cb 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -391,30 +391,13 @@ function Register-EventHandlers {
if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
$localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
}
- if ($null -ne $localState.Data.customVMHostIP -and -not [string]::IsNullOrWhiteSpace($localState.Data.customVMHostIP)) {
- $localState.Controls.txtVMHostIPAddress.Text = $localState.Data.customVMHostIP
- }
}
else {
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
- if ($null -ne $selectedItem -and $localState.Data.vmSwitchMap.ContainsKey($selectedItem)) {
- $localState.Controls.txtVMHostIPAddress.Text = $localState.Data.vmSwitchMap[$selectedItem]
- }
- else {
- $localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP if not found or key null
- }
}
})
- # Persist custom VM switch name/IP when user edits them while 'Other' is selected
- $State.Controls.txtVMHostIPAddress.Add_LostFocus({
- param($eventSource, $routedEventArgs)
- $window = [System.Windows.Window]::GetWindow($eventSource)
- $localState = $window.Tag
- if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
- $localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
- }
- })
+ # Persist custom VM switch name when user edits it while 'Other' is selected
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 94c89b9..c58446d 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -206,20 +206,16 @@ function Initialize-UIControls {
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
- $State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation')
- $State.Controls.txtShareName = $window.FindName('txtShareName')
- $State.Controls.txtUsername = $window.FindName('txtUsername')
$State.Controls.txtThreads = $window.FindName('txtThreads')
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
- $State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
@@ -227,7 +223,6 @@ function Initialize-UIControls {
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
- $State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
@@ -351,18 +346,11 @@ function Initialize-VMSwitchData {
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
- if ($null -ne $firstSwitch -and $State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
- $State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
- }
- else {
- $State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found or key null
- }
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
else {
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
- $State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
}
}
@@ -378,8 +366,6 @@ function Initialize-UIDefaults {
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
- $State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
- $State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
@@ -389,7 +375,6 @@ function Initialize-UIDefaults {
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
- $State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
@@ -398,7 +383,6 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
- $State.Controls.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index 8da5c59..4c1ecbc 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -115,8 +115,6 @@ function Get-GeneralDefaults {
# Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath
- ShareName = "FFUCaptureShare"
- Username = "ffu_user"
Threads = 5
BitsPriority = 'Normal'
MaxUSBDrives = 5
@@ -124,7 +122,6 @@ function Get-GeneralDefaults {
CompactOS = $true
Optimize = $true
AllowVHDXCaching = $false
- CreateCaptureMedia = $true
CreateDeploymentMedia = $true
Verbose = $false
AllowExternalHardDiskMedia = $false
@@ -136,7 +133,6 @@ function Get-GeneralDefaults {
CopyPPKG = $false
InjectUnattend = $false
CleanupAppsISO = $true
- CleanupCaptureISO = $true
CleanupDeployISO = $true
CleanupDrivers = $false
RemoveFFU = $false
@@ -144,7 +140,6 @@ function Get-GeneralDefaults {
RemoveUpdates = $false
RemoveDownloadedESD = $true
# Hyper-V Settings Defaults
- VMHostIPAddress = ""
DiskSizeGB = 50
MemoryGB = 4
Processors = 4
diff --git a/FFUDevelopment/WinPECaptureFFUFiles/AssignDriveLetter.txt b/FFUDevelopment/WinPECaptureFFUFiles/AssignDriveLetter.txt
deleted file mode 100644
index a18948f..0000000
--- a/FFUDevelopment/WinPECaptureFFUFiles/AssignDriveLetter.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-select disk 0
-select partition 3
-Assign letter="M"
-exit
diff --git a/FFUDevelopment/WinPECaptureFFUFiles/CaptureFFU.ps1 b/FFUDevelopment/WinPECaptureFFUFiles/CaptureFFU.ps1
deleted file mode 100644
index 9be00f4..0000000
--- a/FFUDevelopment/WinPECaptureFFUFiles/CaptureFFU.ps1
+++ /dev/null
@@ -1,237 +0,0 @@
-$VMHostIPAddress = '192.168.1.158'
-$ShareName = 'FFUCaptureShare'
-$UserName = 'ffu_user'
-$Password = '23202eb4-10c3-47e9-b389-f0c462663a23'
-$CustomFFUNameTemplate = '{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}'
-
-$netuseCommand = "net use W: \\$VMHostIPAddress\$ShareName /user:$UserName $Password 2>&1"
-
-# Connect to network share
-try {
- Write-Host "Connecting to network share via $netuseCommand"
- $netUseResult = net use W: "\\$VMHostIPAddress\$ShareName" "/user:$UserName" "$Password" 2>&1
-
- # Check if the result contains an error
- if ($LASTEXITCODE -ne 0) {
- # Extract the error code from the Exception Message
- # Example message format: "System error 53 has occurred."
- $message = $netUseResult.Exception.Message
- $regex = [regex]'System error (\d+)'
- $match = $regex.Match($message)
- if ($match.Success) {
- $errorCode = [int]$match.Groups[1].Value
-
- $errorMessage = switch ($errorCode) {
- 53 { "Network path not found. Verify the IP address is correct and the server is accessible." }
- 67 { "Network name cannot be found. Verify the share name exists on the server." }
- 86 { "Password is incorrect for the specified username." }
- 1219 { "Multiple connections to the share exist."}
- 1326 { "Logon failure: unknown username or bad password." }
- 1385 { "Logon failure: the user has not been granted the requested logon type at this computer.
- This is likely due to changes to the User Rights Assignment: Access this computer from the network local security policy
- See: https://github.com/rbalsleyMSFT/FFU/issues/122 for more info" }
- 1792 { "Unable to connect. Verify the server is running and accepting connections." }
- 2250 { "Network connection attempt timed out." }
- default { "Network connection failed with error code: $errorCode. Details: $message" }
- }
- # Write-Error $errorMessage
- throw $errorMessage
- }
- }
-} catch {
- Write-Error "Failed to connect to network share: Error code: $errorcode $_"
- Write-Host "Some things to try:"
- Write-Host '1. If not using an external switch, change to using an external switch'
- Write-Host '2. Make sure the VMHostIPAddress is correct for the VMSwitch that is being used'
- Write-Host '3. Try disabling the Windows Firewall on the host machine as a test only. If that helps, there is a Windows firewall rule that is blocking SMB 445 into the VM host'
- Write-Host '4. If this is a machine that is managed by your organization, try using another machine that is not managed. There could be security policies in place that are blocking the connection to the share.'
- Write-Host '5. You can also try disabling Hyper-V and re-enabling it. This has helped some users in the past.'
- Write-Host '6. If all else fails, open an issue on the github repo and attach screenshots of this message, your FFUDevelopment.log, your command line that you used to build the FFU, and/or the config file you used (if you used one).'
- pause
- throw
-}
-
-$AssignDriveLetter = 'x:\AssignDriveLetter.txt'
-try {
- Write-Host 'Assigning M: as Windows drive letter'
- Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
-}
-catch {
- Write-Error "Failed to assign drive letter using diskpart: $_"
-
-}
-
-#Load Registry Hive
-$Software = 'M:\Windows\System32\config\software'
-try {
- Write-Host "Loading software registry hive to $Software"
- if (-not (Test-Path -Path $Software)) {
- throw "Software registry hive not found at $Software"
- }
- $regResult = reg load "HKLM\FFU" $Software 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
- }
- Write-Host "Successfully loaded software registry hive."
-}
-catch {
- Write-Error "Failed to load registry hive: $_"
-
-}
-
-try {
- #Find Windows version values
- Write-Host "Retrieving Windows information from the registry..."
- $SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
- Write-Host "SKU: $SKU"
- [int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
- Write-Host "CurrentBuild: $CurrentBuild"
- if ($CurrentBuild -notin 14393, 17763) {
- Write-Host "CurrentBuild is not 14393 or 17763, retrieving WindowsVersion..."
- $WindowsVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
- Write-Host "WindowsVersion: $WindowsVersion"
- }
- $InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType'
- Write-Host "InstallationType: $InstallationType"
- $BuildDate = Get-Date -uformat %b%Y
- Write-Host "BuildDate: $BuildDate"
-
-$SKU = switch ($SKU) {
- Core { 'Home' }
- CoreN { 'Home_N' }
- CoreSingleLanguage { 'Home_SL' }
- Professional { 'Pro' }
- ProfessionalN { 'Pro_N' }
- ProfessionalEducation { 'Pro_Edu' }
- ProfessionalEducationN { 'Pro_Edu_N' }
- Enterprise { 'Ent' }
- EnterpriseN { 'Ent_N' }
- EnterpriseS { 'Ent_LTSC' }
- EnterpriseSN { 'Ent_N_LTSC' }
- IoTEnterpriseS { 'IoT_Ent_LTSC' }
- Education { 'Edu' }
- EducationN { 'Edu_N' }
- ProfessionalWorkstation { 'Pro_Wks' }
- ProfessionalWorkstationN { 'Pro_Wks_N' }
- ServerStandard { 'Srv_Std' }
- ServerDatacenter { 'Srv_Dtc' }
-}
-
- if ($InstallationType -eq "Client") {
- if ($CurrentBuild -ge 22000) {
- $WindowsRelease = 'Win11'
- Write-Host "WindowsRelease: $WindowsRelease"
- }
- else {
- $WindowsRelease = 'Win10'
- Write-Host "WindowsRelease: $WindowsRelease"
- }
- }
- else {
- $WindowsRelease = switch ($CurrentBuild) {
- 26100 { '2025' }
- 20348 { '2022' }
- 17763 { '2019' }
- 14393 { '2016' }
- Default { $WindowsVersion }
- }
- Write-Host "WindowsRelease: $WindowsRelease"
- if ($InstallationType -eq "Server Core") {
- $SKU += "_Core"
- Write-Host "InstallType is Server Core, changing SKU to: $SKU"
- }
- }
-
- if ($CustomFFUNameTemplate) {
- Write-Host 'Using custom FFU name template...'
- $FFUFileName = $CustomFFUNameTemplate
- $FFUFileName = $FFUFileName -replace '{WindowsRelease}', $WindowsRelease
- $FFUFileName = $FFUFileName -replace '{WindowsVersion}', $WindowsVersion
- $FFUFileName = $FFUFileName -replace '{SKU}', $SKU
- $FFUFileName = $FFUFileName -replace '{BuildDate}', $BuildDate
- $FFUFileName = $FFUFileName -replace '{yyyy}', (Get-Date -UFormat '%Y')
- $FFUFileName = $FFUFileName -creplace '{MM}', (Get-Date -UFormat '%m')
- $FFUFileName = $FFUFileName -replace '{dd}', (Get-Date -UFormat '%d')
- $FFUFileName = $FFUFileName -creplace '{HH}', (Get-Date -UFormat '%H')
- $FFUFileName = $FFUFileName -creplace '{hh}', (Get-Date -UFormat '%I')
- $FFUFileName = $FFUFileName -creplace '{mm}', (Get-Date -UFormat '%M')
- $FFUFileName = $FFUFileName -replace '{tt}', (Get-Date -UFormat '%p')
- Write-Host "FFU File Name: $FFUFileName"
- #If the custom FFU name template does not end with .ffu, append it
- if ($FFUFileName -notlike '*.ffu') {
- $FFUFileName += '.ffu'
- Write-Host "Appended .ffu to FFU file name: $FFUFileName"
- }
- $dismArgs = "/capture-ffu /imagefile=W:\$FFUFileName /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
- Write-Host "DISM arguments for capture: $dismArgs"
- }
- else {
- #If Office is installed, modify the file name of the FFU
- $Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
- if ($Office) {
- $ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
- Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
- }
- else {
- $ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu"
- Write-Host "Office is not installed, using modified FFU file name: $ffuFilePath"
- }
- $dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
- Write-Host "DISM arguments for capture: $dismArgs"
- }
-
- #Unload Registry
- Set-Location X:\
- Remove-Variable SKU
- Remove-Variable CurrentBuild
- if ($CurrentBuild -notin 14393, 17763) {
- Remove-Variable WindowsVersion
- }
- if ($Office) {
- Remove-Variable Office
- }
-
- try {
- Write-Host "Unloading registry hive HKLM\FFU..."
- $regUnloadResult = reg unload "HKLM\FFU" 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
- }
- Write-Host "Successfully unloaded registry hive."
- }
- catch {
- Write-Error "Failed to unload registry hive: $_"
-
- }
-
- Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
- Start-sleep 60
-
- try {
- Write-Host "Starting DISM FFU capture..."
- $dismProcess = Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop
- if ($dismProcess.ExitCode -ne 0) {
- throw "DISM capture failed with exit code $($dismProcess.ExitCode)"
- }
- Write-Host "DISM FFU capture completed successfully."
- }
- catch {
- Write-Error "FFU capture failed: $_"
-
- }
-
- try {
- Write-Host "Copying DISM log to network share..."
- xcopy X:\Windows\logs\dism\dism.log W:\ /Y | Out-Null
- }
- catch {
- Write-Warning "Failed to copy DISM log: $_"
- }
- Write-Host "DISM log copied to network share, shutting down..."
- wpeutil Shutdown
-
-}
-catch {
- Write-Error "An unexpected error occurred: $_"
-
-}
diff --git a/FFUDevelopment/WinPECaptureFFUFiles/Windows/System32/startnet.cmd b/FFUDevelopment/WinPECaptureFFUFiles/Windows/System32/startnet.cmd
deleted file mode 100644
index ed54781..0000000
--- a/FFUDevelopment/WinPECaptureFFUFiles/Windows/System32/startnet.cmd
+++ /dev/null
@@ -1,5 +0,0 @@
-wpeinit
-powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
-powershell -Noprofile -ExecutionPolicy Bypass -File x:\CaptureFFU.ps1
-exit
-
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index e8b1e6a00dc90959d716f7536966fcccdc61eb96..fff1a49346ba52d9a682469b35b85a93dba4a32e 100644
GIT binary patch
delta 26
icmeA%I;X!ufoXFB(
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
-## Create Capture Media
-
-Controls the `-CreateCaptureMedia` parameter.
-
-When enabled, FFU Builder creates WinPE capture media that is used during VM-based builds (when apps are installed in the VM). FFU Builder attaches this media to the VM and adjusts boot order so the VM can reboot into WinPE and automatically capture the FFU to your **FFU Capture Location**.
-
-The capture media uses the parameter values from `VMHostIPAddress`, `ShareName`, `UserName`, and `CustomFFUNameTemplate` and inserts them into `CaptureFFU.ps1` which is what is responsible for capturing the FFU from the guest VM to the Host.
-
-**Default:** Enabled (`-CreateCaptureMedia $true`)
-
-{: .note-title}
-
-> Note
->
-> This option is only relevant when **Install Apps** is enabled. If **Install Apps** is enabled, the build forces `-CreateCaptureMedia` to `$true` because capture media is required to capture an FFU from the VM.
-
-{: .tip-title}
-
-> Tip
->
-> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
-
## Create Deployment Media
Controls the `-CreateDeploymentMedia` parameter.
@@ -513,7 +475,7 @@ The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_D
> Tip
>
-> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
+> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
## Inject Unattend.xml
@@ -604,12 +566,6 @@ You may want to disable Cleanup Apps ISO in the following scenarios:
>
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
-## Cleanup Capture ISO
-
-Controls the `-CleanupCaptureISO` parameter. When checked, the WinPE capture ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
-
-It's recommended to keep this checked as each new build re-creates the local username account (e.g. `ffu_user`) and its password. If you were to retain the capture ISO from a previous build, it'd be using an old password and the capture would fail.
-
## Cleanup Deploy ISO
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
diff --git a/docs/create_pemedia.md b/docs/create_pemedia.md
index c7b7720..6110fc5 100644
--- a/docs/create_pemedia.md
+++ b/docs/create_pemedia.md
@@ -9,7 +9,7 @@ parent: Helper Scripts
---
# Create PE Media
-`Create-PEMedia.ps1` is a standalone helper script that creates WinPE capture or deployment ISO files outside the main build flow.
+`Create-PEMedia.ps1` is a standalone helper script that creates WinPE deployment ISO files outside the main build flow.
This is useful when admins need to quickly generate a deploy ISO for a share (or local staging folder) that technicians will use with `USBImagingToolCreator.ps1`.
@@ -40,25 +40,19 @@ Default output file:
Create deploy ISO for x64:
```powershell
-.\Create-PEMedia.ps1 -Deploy $true -WindowsArch 'x64'
+.\Create-PEMedia.ps1 -WindowsArch 'x64'
```
Create deploy ISO for ARM64:
```powershell
-.\Create-PEMedia.ps1 -Deploy $true -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso"
-```
-
-Create capture ISO only:
-
-```powershell
-.\Create-PEMedia.ps1 -Capture $true -Deploy $false
+.\Create-PEMedia.ps1 -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso"
```
Create deploy ISO and include PE drivers from `.\PEDrivers`:
```powershell
-.\Create-PEMedia.ps1 -Deploy $true -CopyPEDrivers $true
+.\Create-PEMedia.ps1 -CopyPEDrivers $true
```
## Stage output for USB imaging
diff --git a/docs/hyperv_settings.md b/docs/hyperv_settings.md
index 0887f1d..2e58bbd 100644
--- a/docs/hyperv_settings.md
+++ b/docs/hyperv_settings.md
@@ -15,11 +15,7 @@ parent: UI Overview
Drop down of detected VM Switches. There's also an **Other** option which allows you to specify a VM Switch Name. The other option is useful in scenarios where the machine you're running the UI from isn't going to be the machine where you plan to build the FFU from.
-## VM Host IP Address
-
-IP address of the selected Hyper-V switch that will be used for FFU capture. The UI will auto-detected this based on the VM Switch that was selected.
-
-If `$InstallApps` is set to `$true`, this parameter must be configured.
+This setting is now optional for FFU capture itself. VM-based builds still capture from the host-side VHDX after the VM shuts down, so you only need a switch when the VM requires network connectivity during provisioning.
## Disk Size (GB)
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index c3e0688..14277d1 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -27,7 +27,6 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -BuildUSBDrive | bool | Build USB Drive | When set to $true, will partition and format a USB drive and copy the captured FFU to the drive. |
| -Cleanup | switch | Monitor cancel build action (no direct control) | Switch to run cleanup-only mode. When specified, the script performs cleanup and exits without starting a new build. |
| -CleanupAppsISO | bool | Cleanup Apps ISO | When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. |
-| -CleanupCaptureISO | bool | Cleanup Capture ISO | When set to $true, will remove the WinPE capture ISO after the FFU has been captured. Default is $true. |
| -CleanupCurrentRunDownloads | bool | Monitor cancel prompt option (no direct control) | When set to $true, cleanup mode will remove downloads created during the current run and restore backed up run JSON files. Default is $false. |
| -CleanupDeployISO | bool | Cleanup Deploy ISO | When set to $true, will remove the WinPE deployment ISO after the FFU has been captured. Default is $true. |
| -CleanupDrivers | bool | Cleanup Drivers | When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. |
@@ -40,7 +39,6 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
| -CopyPPKG | bool | Copy Provisioning Package | 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. |
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is $false. |
-| -CreateCaptureMedia | bool | Create Capture Media | When set to $true, this will create WinPE capture media for use when $InstallApps is set to $true. This capture media will be automatically attached to the VM, and the boot order will be changed to automate the capture of the FFU. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. |
@@ -73,7 +71,6 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -RemoveApps | bool | Remove Apps Folder Content | When set to $true, will remove the application content in the Apps folder after the FFU has been captured. Default is $true. |
| -RemoveFFU | bool | Remove FFU | When set to $true, will remove the FFU file from the $FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is $false. |
| -RemoveUpdates | bool | Remove Downloaded Update Files | When set to $true, will remove the downloaded CU, MSRT, Defender, Edge, OneDrive, and .NET files downloaded. Default is $true. |
-| -ShareName | string | Share Name | Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed. |
| -Threads | int | Threads | Controls the throttle applied to parallel tasks inside the script. Default is 5, matching the UI Threads field, and applies to driver downloads invoked through Invoke-ParallelProcessing. |
| -UpdateADK | bool | Update ADK | When set to $true, the script will check for and install the latest Windows ADK and WinPE add-on if they are not already installed or up-to-date. Default is $true. |
| -UpdateEdge | bool | Update Edge | When set to $true, will download and install the latest Microsoft Edge. Default is $false. |
@@ -88,10 +85,8 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -UseDriversAsPEDrivers | bool | Use Drivers Folder as PE Drivers Source | When set to $true (and -CopyPEDrivers is also $true), bypasses the contents of $FFUDevelopmentPath\PEDrivers and instead builds the WinPE driver set dynamically from the $DriversFolder path, copying only the required WinPE drivers. Has no effect if -CopyPEDrivers is not specified. Default is $false. |
| -UserAgent | string | CLI only (no UI control) | User agent string to use when downloading files. |
| -UserAppListPath | string | Application Path (derived UserAppList.json) | Path to a JSON file containing a list of user-defined applications to install. Default is $FFUDevelopmentPath\Apps\UserAppList.json. |
-| -Username | string | Username | Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account. |
-| -VMHostIPAddress | string | VM Host IP Address | IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this, or use the UI to auto-detect. |
| -VMLocation | string | VM Location | Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. |
-| -VMSwitchName | string | VM Switch Name + Custom VM Switch Name (when Other selected) | Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set to capture the FFU from the VM. |
+| -VMSwitchName | string | VM Switch Name + Custom VM Switch Name (when Other selected) | Name of the Hyper-V virtual switch. Provide it when the VM needs network connectivity during provisioning. |
| -WindowsArch | string | Windows Architecture | String value of 'x86', 'x64', or 'arm64'. This is used to identify which architecture of Windows to download. Default is 'x64'. |
| -WindowsLang | string | Windows Language | String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'. |
| -WindowsRelease | int | Windows Release | Integer value of 10, 11, 2016, 2019, 2021, 2022, 2024, or 2025. This is used to identify which Windows client/LTSC/server release to use. Default is 11. |
diff --git a/docs/quickstart.md b/docs/quickstart.md
index ef62c7d..451b237 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -45,7 +45,7 @@ Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting
Click the Hyper-V Settings tab
-You should be able to keep these settings at the defaults. For VM Switch Name and VM Host IP Address, you'll want to make sure the switch you created in the prerequisites section is listed. FFU Builder should automatically figure out the IP address of the swtich for you.
+You should be able to keep these settings at the defaults. If the VM needs network connectivity during provisioning, make sure the switch you created in the prerequisites section is listed under VM Switch Name. If the build does not need VM networking, you can leave the switch unset.
One setting you might need to set is the Logical Sector Size. 512 is the default and that's what most physical disks use today. However 4kn drives and UFS drives use 4k sector sizes, which would require you to select 4096. For now, select 512. If you have issues during deployment, there is error logging that will tell you if you need to set to 4096 on your device.
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 15/30] 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
From f838ef3779058cdfd9ba48e603739ac321230a4a Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:49:17 -0700
Subject: [PATCH 16/30] Remove registry-based FFU file naming
Replace the Get-WindowsVersionInfo registry-interrogation step in the VHDX capture path with parameter-driven naming via Get-FFUCaptureNamingInfo. This eliminates the registry load/unload commands and two full minutes of registry sleep time during capture.
By deriving default file and DISM image names from already-resolved target state (WindowsRelease, WindowsVersion, and installationType), capture naming now naturally matches the rest of the script. This also unifies CustomFFUNameTemplate evaluation between live image servicing and cached VHDX reuse instances.
---
FFUDevelopment/BuildFFUVM.ps1 | 180 ++++++++++++----------------------
1 file changed, 65 insertions(+), 115 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index 02d45a2..14e4e50 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -3680,88 +3680,97 @@ function Get-ShortenedWindowsSKU {
return $shortenedWindowsSKU
}
+function Get-FFUCaptureNamingInfo {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$ShortenedWindowsSKU,
+
+ [Parameter(Mandatory = $true)]
+ [int]$WindowsRelease,
+
+ [Parameter(Mandatory = $true)]
+ [string]$WindowsVersion,
+
+ [Parameter(Mandatory = $true)]
+ [string]$InstallationType,
+
+ [bool]$IsWindows10LtscClient = $false
+ )
+
+ $windowsReleaseToken = [string]$WindowsRelease
+ if ($InstallationType -eq 'Client') {
+ if (($WindowsRelease -eq 10) -or $IsWindows10LtscClient) {
+ $windowsReleaseToken = 'Win10'
+ }
+ else {
+ $windowsReleaseToken = 'Win11'
+ }
+ }
+
+ $defaultFilePrefix = if ($InstallationType -eq 'Client') { $windowsReleaseToken } else { "Server$WindowsRelease" }
+ $buildDate = Get-Date -uformat %b%Y
+
+ return [pscustomobject]@{
+ WindowsReleaseToken = $windowsReleaseToken
+ WindowsVersion = $WindowsVersion
+ BuildDate = $buildDate
+ DefaultFileName = "$defaultFilePrefix`_$WindowsVersion`_$ShortenedWindowsSKU`_$buildDate.ffu"
+ CaptureName = "$windowsReleaseToken$WindowsVersion$ShortenedWindowsSKU"
+ }
+}
+
function New-FFUFileName {
- # $Winverinfo.name will be either Win10 or Win11 for client OSes
- # Since WindowsRelease now includes dates, it breaks default name template in the config file
- # This should keep in line with the naming that's done via VM Captures
- if ($installationType -eq 'Client' -and $winverinfo) {
- $WindowsRelease = $winverinfo.name
- }
-
- $BuildDate = Get-Date -uformat %b%Y
+ $ffuCaptureNamingInfo = Get-FFUCaptureNamingInfo -ShortenedWindowsSKU $shortenedWindowsSKU -WindowsRelease $WindowsRelease -WindowsVersion $WindowsVersion -InstallationType $installationType -IsWindows10LtscClient:$isWindows10LtscClient
+ $resolvedFFUNameTemplate = $CustomFFUNameTemplate
+
# Replace '{WindowsRelease}' with the Windows release (e.g., 10, 11, 2016, 2019, 2022, 2025)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{WindowsRelease}', $WindowsRelease
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{WindowsRelease}', $ffuCaptureNamingInfo.WindowsReleaseToken
# Replace '{WindowsVersion}' with the Windows version (e.g., 1607, 1809, 21h2, 22h2, 23h2, 24h2, etc)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{WindowsVersion}', $WindowsVersion
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{WindowsVersion}', $ffuCaptureNamingInfo.WindowsVersion
# Replace '{SKU}' with the SKU of the Windows image (e.g., Pro, Enterprise, etc.)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{SKU}', $shortenedWindowsSKU
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{SKU}', $shortenedWindowsSKU
# Replace '{BuildDate}' with the current month and year (e.g., Jan2023)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{BuildDate}', $BuildDate
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{BuildDate}', $ffuCaptureNamingInfo.BuildDate
# Replace '{yyyy}' with the current year in 4-digit format (e.g., 2023)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{yyyy}', (Get-Date -UFormat '%Y')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{yyyy}', (Get-Date -UFormat '%Y')
# Replace '{MM}' with the current month in 2-digit format (e.g., 01 for January)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{MM}', (Get-Date -UFormat '%m')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -creplace '{MM}', (Get-Date -UFormat '%m')
# Replace '{dd}' with the current day of the month in 2-digit format (e.g., 05)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{dd}', (Get-Date -UFormat '%d')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{dd}', (Get-Date -UFormat '%d')
# Replace '{HH}' with the current hour in 24-hour format (e.g., 14 for 2 PM)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{HH}', (Get-Date -UFormat '%H')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -creplace '{HH}', (Get-Date -UFormat '%H')
# Replace '{hh}' with the current hour in 12-hour format (e.g., 02 for 2 PM)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{hh}', (Get-Date -UFormat '%I')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -creplace '{hh}', (Get-Date -UFormat '%I')
# Replace '{mm}' with the current minute in 2-digit format (e.g., 09)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{mm}', (Get-Date -UFormat '%M')
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -creplace '{mm}', (Get-Date -UFormat '%M')
# Replace '{tt}' with the current AM/PM designator (e.g., AM or PM)
- $CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{tt}', (Get-Date -UFormat '%p')
- if ($CustomFFUNameTemplate -notlike '*.ffu') {
- $CustomFFUNameTemplate += '.ffu'
+ $resolvedFFUNameTemplate = $resolvedFFUNameTemplate -replace '{tt}', (Get-Date -UFormat '%p')
+ if ($resolvedFFUNameTemplate -notlike '*.ffu') {
+ $resolvedFFUNameTemplate += '.ffu'
}
- return $CustomFFUNameTemplate
+ return $resolvedFFUNameTemplate
}
function New-FFU {
$captureContext = Get-CaptureVhdContext -VhdxPath $VHDXPath
$captureDisk = $captureContext.Disk
- $osPartitionDriveLetter = $captureContext.OsPartitionDriveLetter
- $WindowsPartition = $captureContext.WindowsPartition
+ $ffuCaptureNamingInfo = Get-FFUCaptureNamingInfo -ShortenedWindowsSKU $shortenedWindowsSKU -WindowsRelease $WindowsRelease -WindowsVersion $WindowsVersion -InstallationType $installationType -IsWindows10LtscClient:$isWindows10LtscClient
try {
Set-Progress -Percentage 68 -Message "Capturing FFU from VHDX..."
- if ($InstallApps -or (-not $AllowVHDXCaching)) {
- # Resolve live Windows metadata from the mounted VHDX when the image was customized in a VM.
- $winverinfo = Get-WindowsVersionInfo
- WriteLog 'Creating FFU File Name'
- if ($CustomFFUNameTemplate) {
- $FFUFileName = New-FFUFileName
- }
- else {
- $FFUFileName = "$($winverinfo.Name)`_$($winverinfo.DisplayVersion)`_$($shortenedWindowsSKU)`_$($winverinfo.BuildDate).ffu"
- }
- WriteLog "FFU file name: $FFUFileName"
- $FFUFile = "$FFUCaptureLocation\$FFUFileName"
- WriteLog 'Capturing FFU from mounted VHDX on host'
- Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($captureDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
+ WriteLog 'Creating FFU File Name'
+ if ($CustomFFUNameTemplate) {
+ $FFUFileName = New-FFUFileName
}
else {
- # Use cached Windows metadata only when the VHDX contents were reused without VM customization.
- WriteLog 'Creating FFU File Name'
- if ($CustomFFUNameTemplate) {
- $FFUFileName = New-FFUFileName
- }
- else {
- $BuildDate = Get-Date -UFormat %b%Y
- if ($installationType -eq 'Client') {
- $FFUFileName = "Win$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
- }
- else {
- $FFUFileName = "Server$($cachedVHDXInfo.WindowsRelease)`_$($cachedVHDXInfo.WindowsVersion)`_$($shortenedWindowsSKU)`_$BuildDate.ffu"
- }
- }
- WriteLog "FFU file name: $FFUFileName"
- $FFUFile = "$FFUCaptureLocation\$FFUFileName"
- WriteLog 'Capturing FFU from mounted VHDX on host'
- Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($captureDisk.DiskNumber) /Name:$($cachedVHDXInfo.WindowsRelease)$($cachedVHDXInfo.WindowsVersion)$($shortenedWindowsSKU) /Compress:Default" | Out-Null
+ $FFUFileName = $ffuCaptureNamingInfo.DefaultFileName
}
+ WriteLog "FFU file name: $FFUFileName"
+ $FFUFile = "$FFUCaptureLocation\$FFUFileName"
+ WriteLog 'Capturing FFU from mounted VHDX on host'
+ Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($captureDisk.DiskNumber) /Name:$($ffuCaptureNamingInfo.CaptureName) /Compress:Default" | Out-Null
WriteLog 'FFU Capture complete'
}
@@ -3873,65 +3882,6 @@ function Remove-FFUVM {
Invoke-Process cmd "/c mountvol /r" | Out-Null
WriteLog 'Removal complete'
}
-Function Get-WindowsVersionInfo {
- #This sleep prevents CBS/CSI corruption which causes issues with Windows update after deployment. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. This seems to affect VHDX-only captures, not VM captures.
- WriteLog 'Sleep 60 seconds before opening registry to grab Windows version info '
- Start-sleep 60
- WriteLog "Getting Windows Version info"
- #Load Registry Hive
- $Software = "$osPartitionDriveLetter`:\Windows\System32\config\software"
- WriteLog "Loading Software registry hive: $Software"
- Invoke-Process reg "load HKLM\FFU $Software" | Out-Null
-
- #Find Windows version values
- # $WindowsSKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
- # WriteLog "Windows SKU: $WindowsSKU"
- [int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
- WriteLog "Windows Build: $CurrentBuild"
- #DisplayVersion does not exist for 1607 builds (RS1 and Server 2016) and Server 2019
- if ($CurrentBuild -notin (14393, 17763)) {
- $DisplayVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
- WriteLog "Windows Version: $DisplayVersion"
- }
- # For Windows 10 LTSC 2019, set DisplayVersion to 2019
- if ($CurrentBuild -eq 17763 -and $InstallationType -eq "Client") {
- $DisplayVersion = '2019'
- }
-
- $BuildDate = Get-Date -uformat %b%Y
-
- if ($shortenedWindowsSKU -notmatch "Srv") {
- if ($CurrentBuild -ge 22000) {
- $Name = 'Win11'
- }
- else {
- $Name = 'Win10'
- }
- }
- else {
- $Name = switch ($CurrentBuild) {
- 26100 { '2025' }
- 20348 { '2022' }
- 17763 { '2019' }
- 14393 { '2016' }
- Default { $DisplayVersion }
- }
- }
-
- WriteLog "Unloading registry"
- Invoke-Process reg "unload HKLM\FFU" | Out-Null
- #This prevents Critical Process Died errors you can have during deployment of the FFU. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for.
- WriteLog 'Sleep 60 seconds to allow registry to completely unload'
- Start-sleep 60
-
- return @{
-
- DisplayVersion = $DisplayVersion
- BuildDate = $buildDate
- Name = $Name
- # SKU = $WindowsSKU
- }
-}
Function Get-USBDrive {
# Log the start of the USB drive check
WriteLog 'Checking for USB drives'
From 78212f06d7bb82405981ad17d0436c871c9d173e Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Mon, 30 Mar 2026 16:37:53 -0700
Subject: [PATCH 17/30] Add experimental VM networking opt-in for Hyper-V
builds
The Hyper-V switch selection in the UI previously did not connect the build VM to the network during provisioning. Since internet-connected Sysprep and capture flows are still experimental, this introduces an explicit "Enable VM Networking" checkbox to the Hyper-V Settings page that defaults to off.
- Adds the `-EnableVMNetworking` parameter to BuildFFUVM.ps1 to conditionally attach the Hyper-V network adapter during VM creation.
- Persists the setting through FFU config files and blocks UI execution if enabled without a valid switch.
- Refactors WPF event handlers in FFUUI.Core to fix dropdown scoping errors.
- Updates documentation and the sample configuration file to reflect the new behavior.
---
FFUDevelopment/BuildFFUVM.ps1 | 60 +++++++++++++++---
FFUDevelopment/BuildFFUVM_UI.ps1 | 7 ++
FFUDevelopment/BuildFFUVM_UI.xaml | 14 ++--
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 6 ++
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 47 +++++++++++---
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 5 +-
FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 1 +
FFUDevelopment/config/Sample_default.json | Bin 6094 -> 6162 bytes
docs/hyperv_settings.md | 8 ++-
docs/parameters_reference.md | 3 +-
docs/quickstart.md | 4 +-
11 files changed, 128 insertions(+), 27 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index 14e4e50..5055dbb 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -90,6 +90,9 @@ Path to a JSON file that specifies which drivers to download.
.PARAMETER ExportConfigFile
Path to a JSON file to export the parameters used for the script.
+.PARAMETER EnableVMNetworking
+When set to $true, connects the build VM to the Hyper-V virtual switch named in -VMSwitchName during provisioning. Default is $false because internet-connected Sysprep is experimental.
+
.PARAMETER FFUCaptureLocation
Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU.
@@ -223,7 +226,7 @@ Path to a JSON file containing a list of user-defined applications to install. D
Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to.
.PARAMETER VMSwitchName
-Name of the Hyper-V virtual switch. Optional when building with InstallApps. Provide it only if the VM needs network connectivity during provisioning.
+Name of the Hyper-V virtual switch used when -EnableVMNetworking is set to $true. Provide it only if the VM needs network connectivity during provisioning.
.PARAMETER WindowsArch
String value of 'x86', 'x64', or 'arm64'. This is used to identify which architecture of Windows to download. Default is 'x64'.
@@ -317,6 +320,7 @@ param(
[uint64]$Memory = 4GB,
[uint64]$Disksize = 50GB,
[int]$Processors = 4,
+ [bool]$EnableVMNetworking,
[string]$VMSwitchName,
[string]$VMLocation,
[string]$FFUPrefix = '_FFU',
@@ -501,6 +505,13 @@ if ($ConfigFile -and (Test-Path -Path $ConfigFile)) {
}
}
+$vmSwitchWasExplicitlyBound = $PSBoundParameters.ContainsKey('VMSwitchName')
+$enableVmNetworkingWasExplicitlyBound = $PSBoundParameters.ContainsKey('EnableVMNetworking')
+if (-not $EnableVMNetworking -and $vmSwitchWasExplicitlyBound -and -not $enableVmNetworkingWasExplicitlyBound) {
+ $EnableVMNetworking = $true
+ WriteLog 'EnableVMNetworking not explicitly set. Enabling VM networking because -VMSwitchName was supplied on the command line.'
+}
+
# Validate that the selected Windows SKU is compatible with the chosen Windows release and ensure an ISO is provided for unsupported releases
$clientSKUs = @(
'Home',
@@ -2899,6 +2910,27 @@ function New-FFUVM {
$VM = New-VM -Name $VMName -Path $VMPath -MemoryStartupBytes $memory -VHDPath $VHDXPath -Generation 2
Set-VMProcessor -VMName $VMName -Count $processors
+ # Connect the VM to the requested switch only when the experimental networking flag is enabled.
+ if ($EnableVMNetworking) {
+ $primaryVmNetworkAdapter = Get-VMNetworkAdapter -VMName $VMName -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($null -ne $primaryVmNetworkAdapter) {
+ if ($primaryVmNetworkAdapter.SwitchName -eq $VMSwitchName) {
+ WriteLog "VM '$VMName' is already connected to Hyper-V switch '$VMSwitchName'."
+ }
+ else {
+ Connect-VMNetworkAdapter -VMNetworkAdapter $primaryVmNetworkAdapter -SwitchName $VMSwitchName -ErrorAction Stop
+ WriteLog "Connected VM '$VMName' to Hyper-V switch '$VMSwitchName'."
+ }
+ }
+ else {
+ Add-VMNetworkAdapter -VMName $VMName -SwitchName $VMSwitchName -Name 'FFUNetworkAdapter' -ErrorAction Stop | Out-Null
+ WriteLog "Added VM network adapter for '$VMName' on Hyper-V switch '$VMSwitchName'."
+ }
+ }
+ else {
+ WriteLog "VM networking is disabled for '$VMName'."
+ }
+
#Mount AppsISO
Add-VMDvdDrive -VMName $VMName -Path $AppsISO
@@ -5500,14 +5532,26 @@ if ($CopyUnattend) {
if (($InstallOffice -eq $true) -and ($InstallApps -eq $false)) {
throw "If variable InstallOffice is set to `$true, InstallApps must also be set to `$true."
}
-if ($VMSwitchName) {
- WriteLog "Validating -VMSwitchName $VMSwitchName"
- #Check $VMSwitchName by using Get-VMSwitch
- $VMSwitch = Get-VMSwitch -Name $VMSwitchName -ErrorAction SilentlyContinue
- if (-not $VMSwitch) {
- throw "-VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again."
+if ($EnableVMNetworking) {
+ if ($InstallApps -eq $false) {
+ WriteLog 'EnableVMNetworking is set to true, but InstallApps is false. No VM will be created, so VM networking will be ignored.'
}
- WriteLog '-VMSwitchName validation complete'
+ else {
+ if ([string]::IsNullOrWhiteSpace($VMSwitchName)) {
+ throw '-EnableVMNetworking requires -VMSwitchName. Select or enter a Hyper-V switch and try again.'
+ }
+
+ WriteLog "Experimental VM networking enabled. Validating -VMSwitchName $VMSwitchName"
+ #Check $VMSwitchName by using Get-VMSwitch
+ $VMSwitch = Get-VMSwitch -Name $VMSwitchName -ErrorAction SilentlyContinue
+ if (-not $VMSwitch) {
+ throw "-VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again."
+ }
+ WriteLog '-EnableVMNetworking validation complete'
+ }
+}
+elseif ($VMSwitchName) {
+ WriteLog "VM networking is disabled. Stored -VMSwitchName $VMSwitchName will not be used unless -EnableVMNetworking is `$true."
}
if (-not ($ISOPath) -and ($OptionalFeatures -like '*netfx3*')) {
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index fc7eefb..3bf3926 100644
--- a/FFUDevelopment/BuildFFUVM_UI.ps1
+++ b/FFUDevelopment/BuildFFUVM_UI.ps1
@@ -425,6 +425,13 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
+ if ($config.EnableVMNetworking -and $config.InstallApps -and [string]::IsNullOrWhiteSpace([string]$config.VMSwitchName)) {
+ [System.Windows.MessageBox]::Show("Select or enter a VM Switch Name before enabling VM networking.", "VM Switch Required", "OK", "Warning") | Out-Null
+ $btnRun.IsEnabled = $true
+ $script:uiState.Controls.txtStatus.Text = "Build canceled: VM switch required for experimental networking."
+ 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 f645932..de8f077 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -315,11 +315,15 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index 982b5c5..f8791b0 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -44,6 +44,7 @@ function Get-UIConfig {
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
+ EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
FFUPrefix = $State.Controls.txtVMNamePrefix.Text
@@ -466,6 +467,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
# Hyper-V Settings
+ Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
@@ -473,6 +475,10 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
+ $State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
+ if (-not ($true -eq $State.Controls.chkEnableVMNetworking.IsChecked)) {
+ $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
+ }
# Windows Settings
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index ef7c5cb..32a13fb 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -5,6 +5,28 @@
This module is dedicated to managing user interactions within the FFU Builder UI. It contains the Register-EventHandlers function, which connects UI controls defined in the XAML to their corresponding actions in the PowerShell backend. This includes handling button clicks, text input validation, checkbox state changes, and list view interactions across all tabs, effectively wiring up the application's front-end to its core logic.
#>
+function Update-VMNetworkingControls {
+ param([PSCustomObject]$State)
+
+ $isVmNetworkingEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
+ $State.Controls.spVMNetworkingSettings.IsEnabled = $isVmNetworkingEnabled
+
+ if (-not $isVmNetworkingEnabled) {
+ $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
+ return
+ }
+
+ if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
+ $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
+ if ([string]::IsNullOrWhiteSpace($State.Controls.txtCustomVMSwitchName.Text) -and $null -ne $State.Data.customVMSwitchName) {
+ $State.Controls.txtCustomVMSwitchName.Text = $State.Data.customVMSwitchName
+ }
+ }
+ else {
+ $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
+ }
+}
+
function Register-EventHandlers {
param([PSCustomObject]$State)
WriteLog "Registering UI event handlers..."
@@ -379,22 +401,27 @@ function Register-EventHandlers {
})
# Hyper-V tab event handlers
+ $State.Controls.chkEnableVMNetworking.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ Update-VMNetworkingControls -State $localState
+ })
+
+ $State.Controls.chkEnableVMNetworking.Add_Unchecked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ Update-VMNetworkingControls -State $localState
+ })
+
$State.Controls.cmbVMSwitchName.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
# The state object is available via the parent window's Tag property
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
- $selectedItem = $eventSource.SelectedItem
- if ($selectedItem -eq 'Other') {
- $localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
- if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
- $localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
- }
- }
- else {
- $localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
- }
+ Update-VMNetworkingControls -State $localState
})
# Persist custom VM switch name when user edits it while 'Other' is selected
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index c58446d..0d921af 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -205,6 +205,8 @@ function Initialize-UIControls {
$State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
+ $State.Controls.chkEnableVMNetworking = $window.FindName('chkEnableVMNetworking')
+ $State.Controls.spVMNetworkingSettings = $window.FindName('spVMNetworkingSettings')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
@@ -345,7 +347,6 @@ function Initialize-VMSwitchData {
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
- $firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
else {
@@ -398,7 +399,9 @@ function Initialize-UIDefaults {
Update-BitsPrioritySetting -State $State
# Hyper-V Settings defaults from General Defaults
+ $State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
Initialize-VMSwitchData -State $State
+ $State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index 4c1ecbc..6525348 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -140,6 +140,7 @@ function Get-GeneralDefaults {
RemoveUpdates = $false
RemoveDownloadedESD = $true
# Hyper-V Settings Defaults
+ EnableVMNetworking = $false
DiskSizeGB = 50
MemoryGB = 4
Processors = 4
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index fff1a49346ba52d9a682469b35b85a93dba4a32e..689ef22c1cee97af442d0eee7acbba66f5e39a4d 100644
GIT binary patch
delta 51
zcmX@7KgnRjIZ
Date: Tue, 7 Apr 2026 10:48:34 -0700
Subject: [PATCH 18/30] 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 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -969,8 +1003,8 @@
-
-
+
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index f8791b0..1d92654 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -36,6 +36,10 @@ function Get-UIConfig {
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
+ DeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
+ DeviceNameTemplate = $State.Controls.txtDeviceNameTemplate.Text
+ DeviceNamePrefixesPath = $State.Controls.txtDeviceNamePrefixesPath.Text
+ DeviceNamePrefixes = @(Get-DeviceNamePrefixes -State $State)
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
@@ -456,6 +460,24 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
+ Set-UIValue -ControlName 'txtDeviceNamePrefixesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixesPath' -State $State
+ Set-UIValue -ControlName 'txtDeviceNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameTemplate' -State $State
+ Set-UIValue -ControlName 'txtDeviceNamePrefixes' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixes' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
+
+ if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixesPath.Text)) {
+ $State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
+ }
+
+ $deviceNamingMode = 'None'
+ if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
+ $deviceNamingMode = [string]$ConfigContent.DeviceNamingMode
+ }
+ if ($deviceNamingMode -notin @('None', 'Template', 'Prefixes')) {
+ $deviceNamingMode = 'None'
+ }
+ Set-DeviceNamingMode -State $State -Mode $deviceNamingMode
+ Import-DeviceNamePrefixesFromConfiguredPath -State $State
+ Update-DeviceNamingControls -State $State
# Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 32a13fb..5fa99e3 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -27,6 +27,172 @@ function Update-VMNetworkingControls {
}
}
+function Get-SelectedDeviceNamingMode {
+ param([PSCustomObject]$State)
+
+ if ($true -eq $State.Controls.rbDeviceNamingTemplate.IsChecked) {
+ return 'Template'
+ }
+
+ if ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked) {
+ return 'Prefixes'
+ }
+
+ return 'None'
+}
+
+function Set-DeviceNamingMode {
+ param(
+ [PSCustomObject]$State,
+ [ValidateSet('None', 'Template', 'Prefixes')]
+ [string]$Mode
+ )
+
+ $State.Controls.rbDeviceNamingNone.IsChecked = $Mode -eq 'None'
+ $State.Controls.rbDeviceNamingTemplate.IsChecked = $Mode -eq 'Template'
+ $State.Controls.rbDeviceNamingPrefixes.IsChecked = $Mode -eq 'Prefixes'
+}
+
+function Get-DeviceNamePrefixes {
+ param([PSCustomObject]$State)
+
+ if ($null -eq $State.Controls.txtDeviceNamePrefixes) {
+ return @()
+ }
+
+ return @(
+ $State.Controls.txtDeviceNamePrefixes.Text -split "\r?\n" |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ ForEach-Object { $_.Trim() }
+ )
+}
+
+function Import-DeviceNamePrefixesFile {
+ param(
+ [PSCustomObject]$State,
+ [string]$FilePath
+ )
+
+ if ([string]::IsNullOrWhiteSpace($FilePath) -or -not (Test-Path -Path $FilePath -PathType Leaf)) {
+ return $false
+ }
+
+ $prefixLines = @(Get-Content -Path $FilePath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
+ if ($null -ne $State.Controls.txtDeviceNamePrefixesPath) {
+ $State.Controls.txtDeviceNamePrefixesPath.Text = $FilePath
+ }
+ $State.Controls.txtDeviceNamePrefixes.Text = $prefixLines -join [System.Environment]::NewLine
+ WriteLog "Imported device name prefixes from $FilePath"
+ return $true
+}
+
+function Get-DefaultDeviceNamePrefixesPath {
+ param([string]$FFUDevelopmentPath)
+
+ if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
+ return $null
+ }
+
+ return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'prefixes.txt'
+}
+
+function Import-DeviceNamePrefixesFromConfiguredPath {
+ param(
+ [PSCustomObject]$State,
+ [switch]$SkipIfTextPresent
+ )
+
+ if ($SkipIfTextPresent -and -not [string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixes.Text)) {
+ return
+ }
+
+ $prefixFilePath = $State.Controls.txtDeviceNamePrefixesPath.Text
+ if ([string]::IsNullOrWhiteSpace($prefixFilePath)) {
+ $prefixFilePath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
+ if (-not [string]::IsNullOrWhiteSpace($prefixFilePath) -and $null -ne $State.Controls.txtDeviceNamePrefixesPath) {
+ $State.Controls.txtDeviceNamePrefixesPath.Text = $prefixFilePath
+ }
+ }
+
+ if (Test-Path -Path $prefixFilePath -PathType Leaf) {
+ Import-DeviceNamePrefixesFile -State $State -FilePath $prefixFilePath | Out-Null
+ }
+}
+
+function Test-DeviceNameTemplateUsesSerialToken {
+ param([PSCustomObject]$State)
+
+ return ((Get-SelectedDeviceNamingMode -State $State) -eq 'Template') -and ($State.Controls.txtDeviceNameTemplate.Text -match '(?i)%serial%')
+}
+
+function Update-UnattendSelectionControls {
+ param([PSCustomObject]$State)
+
+ $selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
+ $isCopyUnattendSelected = $true -eq $State.Controls.chkCopyUnattend.IsChecked
+ $isInjectUnattendSelected = $true -eq $State.Controls.chkInjectUnattend.IsChecked
+ $deviceNameTemplateUsesSerialToken = Test-DeviceNameTemplateUsesSerialToken -State $State
+
+ if ($isCopyUnattendSelected -and $isInjectUnattendSelected) {
+ if (($selectedDeviceNamingMode -eq 'Prefixes') -or $deviceNameTemplateUsesSerialToken) {
+ $State.Controls.chkInjectUnattend.IsChecked = $false
+ $isInjectUnattendSelected = $false
+ }
+ else {
+ $State.Controls.chkCopyUnattend.IsChecked = $false
+ $isCopyUnattendSelected = $false
+ }
+ }
+
+ if (($selectedDeviceNamingMode -eq 'Prefixes') -or $deviceNameTemplateUsesSerialToken) {
+ if (-not $isCopyUnattendSelected) {
+ $State.Controls.chkCopyUnattend.IsChecked = $true
+ $isCopyUnattendSelected = $true
+ }
+
+ if ($isInjectUnattendSelected) {
+ $State.Controls.chkInjectUnattend.IsChecked = $false
+ $isInjectUnattendSelected = $false
+ }
+
+ $State.Controls.chkCopyUnattend.IsEnabled = $false
+ $State.Controls.chkInjectUnattend.IsEnabled = $false
+ return
+ }
+
+ if ($isCopyUnattendSelected) {
+ $State.Controls.chkCopyUnattend.IsEnabled = $true
+ $State.Controls.chkInjectUnattend.IsEnabled = $false
+ }
+ elseif ($isInjectUnattendSelected) {
+ $State.Controls.chkCopyUnattend.IsEnabled = $false
+ $State.Controls.chkInjectUnattend.IsEnabled = $true
+ }
+ else {
+ $State.Controls.chkCopyUnattend.IsEnabled = $true
+ $State.Controls.chkInjectUnattend.IsEnabled = $true
+ }
+}
+
+function Update-DeviceNamingControls {
+ param([PSCustomObject]$State)
+
+ if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked)) {
+ $State.Controls.rbDeviceNamingNone.IsChecked = $true
+ }
+
+ $selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
+ $State.Controls.deviceNameTemplatePanel.Visibility = if ($selectedDeviceNamingMode -eq 'Template') { 'Visible' } else { 'Collapsed' }
+ $State.Controls.deviceNamePrefixesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'Prefixes') { 'Visible' } else { 'Collapsed' }
+ $State.Controls.rbDeviceNamingPrefixes.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
+
+ if ($selectedDeviceNamingMode -eq 'Prefixes') {
+ Import-DeviceNamePrefixesFromConfiguredPath -State $State -SkipIfTextPresent
+ }
+
+ Update-UnattendSelectionControls -State $State
+}
+
function Register-EventHandlers {
param([PSCustomObject]$State)
WriteLog "Registering UI event handlers..."
@@ -242,7 +408,15 @@ function Register-EventHandlers {
$localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) {
+ $currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
+ $previousDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
$localState.Controls.txtFFUDevPath.Text = $selectedPath
+ $newDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $selectedPath
+ if ([string]::IsNullOrWhiteSpace($currentPrefixesPath) -or $currentPrefixesPath -ieq $previousDefaultPrefixesPath) {
+ $localState.Controls.txtDeviceNamePrefixesPath.Text = $newDefaultPrefixesPath
+ }
+ Import-DeviceNamePrefixesFromConfiguredPath -State $localState
+ Update-DeviceNamingControls -State $localState
}
})
@@ -256,6 +430,106 @@ function Register-EventHandlers {
}
})
+ $State.Controls.rbDeviceNamingNone.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
+ $State.Controls.rbDeviceNamingTemplate.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
+ $State.Controls.txtDeviceNameTemplate.Add_TextChanged({
+ param($eventSource, $textChangedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ if ($null -ne $window -and $null -ne $window.Tag) {
+ Update-DeviceNamingControls -State $window.Tag
+ }
+ })
+ $State.Controls.rbDeviceNamingPrefixes.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
+ $State.Controls.btnBrowseDeviceNamePrefixesPath.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
+ if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
+ $currentPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ }
+ $initialDirectory = if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
+ $null
+ }
+ else {
+ Split-Path $currentPrefixesPath -Parent
+ }
+ $fileName = if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) { 'prefixes.txt' } else { Split-Path $currentPrefixesPath -Leaf }
+ $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select prefixes file path' -Filter 'Text files (*.txt)|*.txt|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
+ if (Import-DeviceNamePrefixesFile -State $localState -FilePath $selectedPath) {
+ Update-DeviceNamingControls -State $localState
+ }
+ })
+ $State.Controls.btnSaveDeviceNamePrefixes.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $prefixLines = @(Get-DeviceNamePrefixes -State $localState)
+
+ if ($prefixLines.Count -eq 0) {
+ [System.Windows.MessageBox]::Show("Enter at least one prefix before saving the prefixes file.", "Prefixes Required", "OK", "Warning") | Out-Null
+ return
+ }
+
+ $currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
+ if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
+ $currentPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ if (-not [string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
+ $localState.Controls.txtDeviceNamePrefixesPath.Text = $currentPrefixesPath
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
+ [System.Windows.MessageBox]::Show("Select a valid Prefixes File Path before saving prefixes.", "Prefixes File Path Required", "OK", "Warning") | Out-Null
+ return
+ }
+
+ try {
+ $prefixLines | Set-Content -Path $currentPrefixesPath -Encoding UTF8
+ $localState.Controls.txtDeviceNamePrefixesPath.Text = $currentPrefixesPath
+ WriteLog "Saved device name prefixes to $currentPrefixesPath"
+ }
+ catch {
+ [System.Windows.MessageBox]::Show("Saving prefixes failed for '$currentPrefixesPath'. $($_.Exception.Message)", "Save Prefixes Failed", "OK", "Error") | Out-Null
+ }
+ })
+ $State.Controls.chkCopyUnattend.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $localState.Controls.chkInjectUnattend.IsChecked = $false
+ Update-DeviceNamingControls -State $localState
+ })
+ $State.Controls.chkCopyUnattend.Add_Unchecked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
+ $State.Controls.chkInjectUnattend.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $localState.Controls.chkCopyUnattend.IsChecked = $false
+ Update-DeviceNamingControls -State $localState
+ })
+ $State.Controls.chkInjectUnattend.Add_Unchecked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
+
# Build USB Drive Settings Event Handlers
# The USB Expander is always visible; the checkbox controls child settings only
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 0d921af..e84e1f5 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -220,6 +220,16 @@ function Initialize-UIControls {
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
+ $State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
+ $State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
+ $State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
+ $State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
+ $State.Controls.deviceNamePrefixesPanel = $window.FindName('deviceNamePrefixesPanel')
+ $State.Controls.txtDeviceNameTemplate = $window.FindName('txtDeviceNameTemplate')
+ $State.Controls.txtDeviceNamePrefixesPath = $window.FindName('txtDeviceNamePrefixesPath')
+ $State.Controls.btnBrowseDeviceNamePrefixesPath = $window.FindName('btnBrowseDeviceNamePrefixesPath')
+ $State.Controls.txtDeviceNamePrefixes = $window.FindName('txtDeviceNamePrefixes')
+ $State.Controls.btnSaveDeviceNamePrefixes = $window.FindName('btnSaveDeviceNamePrefixes')
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
@@ -383,6 +393,12 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
+ Set-DeviceNamingMode -State $State -Mode $State.Defaults.generalDefaults.DeviceNamingMode
+ $State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
+ $State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
+ $State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
+ Import-DeviceNamePrefixesFromConfiguredPath -State $State
+ Update-DeviceNamingControls -State $State
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
index 2ccabde..3aef928 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Shared.psm1
@@ -1173,6 +1173,9 @@ function Invoke-BrowseAction {
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory
}
+ if (-not [string]::IsNullOrWhiteSpace($FileName)) {
+ $dialog.FileName = $FileName
+ }
if ($dialog.ShowDialog()) {
return $dialog.FileName
}
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index 6525348..a841359 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -106,10 +106,12 @@ function Get-GeneralDefaults {
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
+ $unattendPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "unattend"
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
+ $deviceNamePrefixesPath = Join-Path -Path $unattendPath -ChildPath "prefixes.txt"
return [PSCustomObject]@{
# Build Tab Defaults
@@ -132,6 +134,10 @@ function Get-GeneralDefaults {
CopyUnattend = $false
CopyPPKG = $false
InjectUnattend = $false
+ DeviceNamingMode = 'None'
+ DeviceNameTemplate = ''
+ DeviceNamePrefixesPath = $deviceNamePrefixesPath
+ DeviceNamePrefixes = @()
CleanupAppsISO = $true
CleanupDeployISO = $true
CleanupDrivers = $false
diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
index 77b5878..ba94899 100644
--- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
+++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
@@ -64,6 +64,68 @@ function Set-Computername($computername) {
return $computername
}
+function Get-UnattendComputerNameValue {
+ if ($null -eq $UnattendFile) {
+ return $null
+ }
+
+ [xml]$xml = Get-Content $UnattendFile
+ foreach ($component in $xml.unattend.settings.component) {
+ if ($component.ComputerName) {
+ return [string]$component.ComputerName
+ }
+ }
+
+ return $null
+}
+
+function Test-LegacyPromptComputerName($computername) {
+ if ([string]::IsNullOrWhiteSpace($computername)) {
+ return $false
+ }
+
+ $normalizedName = $computername.Trim().ToLowerInvariant()
+ return $normalizedName -in @('mycomputer', 'default')
+}
+
+function Get-NormalizedComputerName($computername) {
+ if ([string]::IsNullOrWhiteSpace($computername)) {
+ throw 'Computer name cannot be empty.'
+ }
+
+ $normalizedName = ($computername -replace "\s", '').Trim()
+ if ([string]::IsNullOrWhiteSpace($normalizedName)) {
+ throw 'Computer name cannot be empty after removing spaces.'
+ }
+
+ if ($normalizedName.Length -gt 15) {
+ $normalizedName = $normalizedName.Substring(0, 15)
+ }
+
+ return $normalizedName
+}
+
+function Resolve-ComputerNameTemplate($computerNameTemplate, $serialNumber) {
+ if ([string]::IsNullOrWhiteSpace($computerNameTemplate)) {
+ throw 'Computer name template cannot be empty.'
+ }
+
+ $resolvedName = $computerNameTemplate -replace '(?i)%serial%', $serialNumber
+ if ($resolvedName -match '%') {
+ throw 'Unsupported device name variable found. Only %serial% is supported.'
+ }
+
+ return Get-NormalizedComputerName($resolvedName)
+}
+
+function Set-ConfiguredComputerName($computername) {
+ $normalizedName = Get-NormalizedComputerName($computername)
+ $normalizedName = Set-Computername($normalizedName)
+ Writelog "Computer name will be set to $normalizedName"
+ Write-Host "Computer name will be set to $normalizedName"
+ return $normalizedName
+}
+
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
@@ -1023,8 +1085,19 @@ If (Test-Path -Path $UnattendComputerNamePath) {
}
}
-#Ask for device name if unattend exists
-If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
+$UnattendConfiguredComputerName = $null
+$RequiresLegacyDeviceNamePrompt = $false
+$RequiresTemplateDeviceName = $false
+if ($Unattend) {
+ $UnattendConfiguredComputerName = Get-UnattendComputerNameValue
+ $RequiresLegacyDeviceNamePrompt = Test-LegacyPromptComputerName($UnattendConfiguredComputerName)
+ if (-not [string]::IsNullOrWhiteSpace($UnattendConfiguredComputerName) -and $UnattendConfiguredComputerName -match '(?i)%serial%') {
+ $RequiresTemplateDeviceName = $true
+ }
+}
+
+#Ask for device name if naming is explicitly required
+If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -or $RequiresLegacyDeviceNamePrompt) {
Write-SectionHeader 'Device Name Selection'
if ($Unattend -and $UnattendPrefix) {
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
@@ -1060,17 +1133,8 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
WriteLog "Will use $PrefixToUse as device name prefix"
Write-Host "Will use $PrefixToUse as device name prefix"
}
- #Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
- #Combine prefix with serial
- $computername = ($PrefixToUse + $serial) -replace "\s", "" # Remove spaces because windows does not support spaces in the computer names
- #If computername is longer than 15 characters, reduce to 15. Sysprep/unattend doesn't like ComputerName being longer than 15 characters even though Windows accepts it
- If ($computername.Length -gt 15) {
- $computername = $computername.substring(0, 15)
- }
- $computername = Set-Computername($computername)
- Writelog "Computer name will be set to $computername"
- Write-Host "Computer name will be set to $computername"
+ $computername = Set-ConfiguredComputerName($PrefixToUse + $serial)
}
elseif ($Unattend -and $UnattendComputerName) {
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
@@ -1080,32 +1144,31 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber }
If ($SCName) {
- [string]$computername = $SCName.ComputerName
- $computername = Set-Computername($computername)
- Writelog "Computer name will be set to $computername"
- Write-Host "Computer name will be set to $computername"
+ [string]$computername = Set-ConfiguredComputerName($SCName.ComputerName)
}
else {
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
- [string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ })))
- $computername = Set-Computername($computername)
- Writelog "Computer name will be set to $computername"
- Write-Host "Computer name will be set to $computername"
+ [string]$computername = Set-ConfiguredComputerName(("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ }))))
}
}
- elseif ($Unattend) {
+ elseif ($Unattend -and $RequiresTemplateDeviceName) {
+ Writelog 'Unattend file found with a %serial% computer name template. Resolving the template.'
+ $serialNumber = (Get-CimInstance -ClassName Win32_Bios).SerialNumber.Trim()
+ [string]$computername = Set-ConfiguredComputerName((Resolve-ComputerNameTemplate -computerNameTemplate $UnattendConfiguredComputerName -serialNumber $serialNumber))
+ }
+ elseif ($Unattend -and $RequiresLegacyDeviceNamePrompt) {
Writelog 'Unattend file found with no prefixes.txt, asking for name'
Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
- [string]$computername = Read-Host 'Enter device name'
- $computername = Set-Computername($computername)
- Writelog "Computer name will be set to $computername"
- Write-Host "Computer name will be set to $computername"
+ [string]$computername = Set-ConfiguredComputerName((Read-Host 'Enter device name'))
}
else {
WriteLog 'Device naming assets detected without unattend.xml. Skipping device naming prompts.'
}
}
+elseif ($Unattend) {
+ WriteLog 'Unattend file found. Device naming is not required, but unattend settings will still be applied.'
+}
else {
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
}
@@ -1568,8 +1631,9 @@ If ($PPKGFileToInstall) {
}
}
#Set DeviceName
-If ($computername) {
- Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration'
+If ($Unattend) {
+ $unattendSectionTitle = if ($computername) { 'Applying Computer Name and Unattend Configuration' } else { 'Applying Unattend Configuration' }
+ Write-SectionHeader -Title $unattendSectionTitle
try {
$PantherDir = 'w:\windows\panther'
If (Test-Path -Path $PantherDir) {
@@ -1590,8 +1654,8 @@ If ($computername) {
}
}
catch {
- WriteLog "Copying Unattend.xml to name device failed"
- Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_"
+ WriteLog 'Copying Unattend.xml to Panther failed'
+ Stop-Script -Message "Copying Unattend.xml to Panther failed with error: $_"
}
}
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index 689ef22c1cee97af442d0eee7acbba66f5e39a4d..af3cdb33d447449dc337a675b20442b2136b4d21 100644
GIT binary patch
delta 217
zcmbPa@XBbzG8TOohE#?!hD?TJAnnJH$dC(U=P{%+_%h@(qyWW~7_1l+fTSOgpU03o
zc_FJPCtSzm|164=d&Gp4F-=Kj2w*4z>Q4ijR{^ApC$6{DE(IEq$WQ`=sX$f=LktiW
mpekd~1Ikt~lz=SKhFb)68it`z=S4HbPG)2h-n@b(OBetJY%mxA
delta 12
TcmaE5G|6DYGM3H%SaXB{Cm{uH
diff --git a/docs/build.md b/docs/build.md
index b80c65f..b19fc83 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -199,24 +199,34 @@ This option is only available when **Build USB Drive** is checked.
When enabled, the build process copies:
- **unattend_x64.xml** (for x64 builds) or **unattend_arm64.xml** (for arm64 builds) → renamed to **Unattend.xml** on the USB drive
-- **prefixes.txt** (if present) → copied alongside the unattend file
+- **prefixes.txt** → created from the **Device Naming** prefixes list when that mode is selected
-During deployment, `ApplyFFU.ps1` detects the `Unattend` folder and uses these files to customize the device name and apply other Windows settings during OOBE.
+During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
#### Device Naming
-Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this:
+Use the **Device Naming** expander to decide whether `ComputerName` should be set at deployment time. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
-#### Prompt for Device Name
+#### No Device Name
-If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive.
+This is the default option. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
-#### Device Naming with prefixes.txt
+#### Specify Device Name
-If a `prefixes.txt` file exists in the `Unattend` folder and there are multiple prefixes in the file, the deployment script prompts the technician to select a prefix from the file. The prefix is combined with the device's serial number to create the computer name. If there is a single prefix, the technician is not prompted and the script will automatically select that prefix.
+Use this option when you want a static device name or a template such as `Comp-%serial%`.
+
+- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
+- With **Inject Unattend.xml**, only static names are supported.
+- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
+
+#### Specify a list of Prefixes
+
+This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line or import an existing prefixes file. The source file can use any name because the UI now tracks the path separately.
+
+If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
@@ -228,6 +238,10 @@ STORE-
KIOSK-
```
+#### Legacy Prompt Behavior
+
+Older deployment media that still has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
+
{: .warning-title}
> Warning
@@ -483,6 +497,8 @@ Controls the `-InjectUnattend` parameter. When checked, copies the architecture-
This option is only available when **Install Apps** is checked.
+`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
+
### How It Works
When enabled, the build process:
@@ -522,7 +538,7 @@ This option is primarily intended for scenarios where:
| Limitation | Description |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **No prefixes.txt support** | Unlike the**Copy Unattend** option for USB drives, this method does not support `prefixes.txt` for dynamic device naming based on serial numbers |
+| **No prefixes.txt or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt` or the `%serial%` variable for deployment-time device naming |
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index 6daff1e..67912f0 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -38,9 +38,13 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyDrivers | bool | Copy Drivers to USB drive | When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. |
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
| -CopyPPKG | bool | Copy Provisioning Package | 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. |
-| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is $false. |
+| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
+| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Template, and Prefixes. The UI uses None, Template, and Prefixes. |
+| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
+| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
+| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. |
| -DriversFolder | string | Drivers Folder | Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. |
| -DriversJsonPath | string | Drivers.json Path | Path to a JSON file that specifies which drivers to download. |
@@ -50,7 +54,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -FFUDevelopmentPath | string | FFU Development Path | Path to the FFU development folder. Default is $PSScriptRoot. |
| -FFUPrefix | string | VM Name Prefix | Prefix for the generated FFU file. Default is _FFU. |
| -Headers | hashtable | CLI only (no UI control) | Headers to use when downloading files. Not recommended to modify. |
-| -InjectUnattend | bool | Inject Unattend.xml | 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. |
+| -InjectUnattend | bool | Inject Unattend.xml | 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. Cannot be used together with -CopyUnattend. Default is $false. |
| -InstallApps | bool | Install Applications | 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. |
| -InstallDrivers | bool | Install Drivers to FFU | Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU. |
| -InstallOffice | bool | Install Office | Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM. |
diff --git a/docs/quickstart.md b/docs/quickstart.md
index add63bb..c1f5e29 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -186,18 +186,26 @@ Another safety measure is **Select Specific USB Drives**. When you check **Selec
**Device Naming**
-Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this:
+Use the **Device Naming** expander on the Build page to decide whether `ComputerName` should be set during deployment. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
-**Prompt for Device Name**
+**No Device Name**
-If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive.
+This is the default option. The unattend file is still applied, but Windows generates a random computer name.
-**Specifying Multiple Name Prefixes**
+**Specify Device Name**
-If you have multiple device name prefixes for different locations or device use cases, or even a single prefix, you can specify a prefixes.txt file in the `C:\FFUDevelopment\unattend` folder. If the prefixes.txt file is detected and a single prefix is listed, the device will just use that prefix and append the serial number of the device. If there are multiple prefixes listed in the prefixes.txt file, you will be prompted to select which prefix you want to name the device and the serial number will be appended to that prefix. If you want a dash in the name, include the dash in the prefix (e.g. if ABCD- is in the prefixes.txt file, the device name will be ABCD-SerialNumber).
+Use this option when you want a static device name or a template such as `Comp-%serial%`.
+
+- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
+- With **Inject Unattend.xml**, only static names are supported.
+- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
+
+**Specify a list of Prefixes**
+
+This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line or import an existing prefixes file. The source file can use any name because the UI tracks the prefixes path separately. If there is one prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one and the serial number is appended to that prefix.
{: .warning-title}
From 5ca5312c521610865a2e5b0d2b76ad81d959435f Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Tue, 7 Apr 2026 10:50:47 -0700
Subject: [PATCH 19/30] Allows Office installation on ARM64 VMs
Comments out the restriction that previously prevented Microsoft 365 Apps and Office from installing on ARM64 virtual machines. This enables users to attempt or perform Office installations on ARM64 architectures where it was previously blocked.
---
FFUDevelopment/BuildFFUVM.ps1 | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index 5055dbb..4309a06 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -5571,10 +5571,10 @@ if (($InstallApps -eq $false) -and (($UpdateLatestDefender -eq $true) -or ($Upda
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"
}
-if (($WindowsArch -eq 'ARM64') -and ($InstallOffice -eq $true)) {
- $InstallOffice = $false
- WriteLog 'M365 Apps/Office currently fails to install on ARM64 VMs without an internet connection. Setting InstallOffice to false'
-}
+# if (($WindowsArch -eq 'ARM64') -and ($InstallOffice -eq $true)) {
+# $InstallOffice = $false
+# WriteLog 'M365 Apps/Office currently fails to install on ARM64 VMs without an internet connection. Setting InstallOffice to false'
+# }
if (($WindowsArch -eq 'ARM64') -and ($UpdateOneDrive -eq $true)) {
$UpdateOneDrive = $false
From 1ea1ef6fd0594caeee47c4ef825a9465b119992c Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Wed, 8 Apr 2026 17:58:25 -0700
Subject: [PATCH 20/30] Improves FFU deployment menu selections and device
naming
Refactors the `ApplyFFU.ps1` script to use a new `Read-MenuSelection` function for standardized user prompts, improving input validation and allowing users to skip optional prompts (like prefix selection, AP JSON, and PPKG files). Updates sample unattend XML files to use `*` for the computer name, falling back to default Windows naming if not specified. Updates the documentation to reflect the new UI and script behaviors.
---
FFUDevelopment/BuildFFUVM_UI.xaml | 2 +-
.../WinPEDeployFFUFiles/ApplyFFU.ps1 | 212 ++---
.../unattend/SampleUnattend_x64.xml | 2 +-
FFUDevelopment/unattend/unattend_arm64.xml | 2 +-
FFUDevelopment/unattend/unattend_x64.xml | 2 +-
docs/build.md | 772 ++++++++++--------
docs/quickstart.md | 6 +
7 files changed, 535 insertions(+), 463 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 4d5f511..7ed2603 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -913,7 +913,7 @@
-
+
diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
index ba94899..9fd106a 100644
--- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
+++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
@@ -41,6 +41,59 @@ function WriteLog($LogText) {
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText"
}
+function Read-MenuSelection {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Prompt,
+
+ [Parameter(Mandatory = $true)]
+ [string]$InvalidInputMessage,
+
+ [Parameter()]
+ [int[]]$ValidSelections,
+
+ [Parameter()]
+ [int]$Minimum = [int]::MinValue,
+
+ [Parameter()]
+ [int]$Maximum = [int]::MaxValue,
+
+ [Parameter()]
+ [switch]$AllowSkip
+ )
+
+ do {
+ $userInput = Read-Host $Prompt
+ if ([string]::IsNullOrWhiteSpace($userInput)) {
+ Write-Host $InvalidInputMessage
+ continue
+ }
+
+ $selection = 0
+ if (-not [int]::TryParse($userInput, [ref]$selection)) {
+ Write-Host $InvalidInputMessage
+ continue
+ }
+
+ if ($AllowSkip -and $selection -eq 0) {
+ return 0
+ }
+
+ if ($PSBoundParameters.ContainsKey('ValidSelections')) {
+ if ($ValidSelections -notcontains $selection) {
+ Write-Host $InvalidInputMessage
+ continue
+ }
+ }
+ elseif ($selection -lt $Minimum -or $selection -gt $Maximum) {
+ Write-Host $InvalidInputMessage
+ continue
+ }
+
+ return $selection
+ } until ($false)
+}
+
function Set-DiskpartAnswerFiles($DiskpartFile, $DiskID) {
(Get-Content $DiskpartFile).Replace('disk 0', "disk $DiskID") | Set-Content -Path $DiskpartFile
}
@@ -953,21 +1006,7 @@ else {
}
$displayList | Format-Table -AutoSize -Property Disk, 'Size (GB)', Sector, 'Bus Type', Model
- do {
- try {
- $var = $true
- [int]$diskSelection = Read-Host 'Enter the disk number to apply the FFU to'
- }
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid disk number'
- $var = $false
- }
- # Validate selected disk is in the list of available disks
- if ($var -and $validDiskIndexes -notcontains $diskSelection) {
- Write-Host "Invalid disk number. Please select from the available disks."
- $var = $false
- }
- } until ($var)
+ $diskSelection = Read-MenuSelection -Prompt 'Enter the disk number to apply the FFU to' -InvalidInputMessage 'Invalid disk number. Please select from the available disks.' -ValidSelections $validDiskIndexes
$selectedDisk = $diskDriveCandidates | Where-Object { $_.Index -eq $diskSelection }
WriteLog "Disk selection: DiskNumber=$($selectedDisk.Index), Model=$($selectedDisk.Model), SizeGB=$([math]::Round(($selectedDisk.Size / 1GB), 2)), BusType=$($selectedDisk.InterfaceType)"
@@ -1011,18 +1050,8 @@ If ($FFUCount -gt 1) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, FFUFile | Out-Host
- do {
- try {
- $var = $true
- [int]$FFUSelected = Read-Host 'Enter the FFU number to install'
- $FFUSelected = $FFUSelected - 1
- }
-
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid FFU number'
- $var = $false
- }
- } until (($FFUSelected -le $FFUCount - 1) -and $var)
+ $FFUSelected = Read-MenuSelection -Prompt 'Enter the FFU number to install' -InvalidInputMessage 'Input was not in correct format. Please enter a valid FFU number.' -Minimum 1 -Maximum $FFUCount
+ $FFUSelected = $FFUSelected - 1
$FFUFileToInstall = $array[$FFUSelected].FFUFile
WriteLog "$FFUFileToInstall was selected"
@@ -1103,6 +1132,8 @@ If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -o
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
$UnattendPrefixCount = $UnattendPrefixes.Count
+ $skipPrefixSelection = $false
+ $PrefixToUse = $null
If ($UnattendPrefixCount -gt 1) {
WriteLog "Found $UnattendPrefixCount Prefixes"
$array = @()
@@ -1111,20 +1142,18 @@ If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -o
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, DeviceNamePrefix
- do {
- try {
- $var = $true
- [int]$PrefixSelected = Read-Host 'Enter the prefix number to use for the device name'
- $PrefixSelected = $PrefixSelected - 1
- }
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid prefix number'
- $var = $false
- }
- } until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
- $PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
- WriteLog "$PrefixToUse was selected"
- Write-Host "`n$PrefixToUse was selected as device name prefix"
+ $prefixSelection = Read-MenuSelection -Prompt 'Enter the prefix number to use for the device name (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid prefix number.' -Minimum 1 -Maximum $UnattendPrefixCount -AllowSkip
+ if ($prefixSelection -eq 0) {
+ $skipPrefixSelection = $true
+ WriteLog 'User chose to skip device name prefix selection. Existing unattend computer name will remain unchanged.'
+ Write-Host "`nDevice name prefix selection was skipped. The existing unattend computer name will remain unchanged."
+ }
+ else {
+ $PrefixSelected = $prefixSelection - 1
+ $PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
+ WriteLog "$PrefixToUse was selected"
+ Write-Host "`n$PrefixToUse was selected as device name prefix"
+ }
}
elseif ($UnattendPrefixCount -eq 1) {
WriteLog "Found $UnattendPrefixCount Prefix"
@@ -1133,8 +1162,10 @@ If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -o
WriteLog "Will use $PrefixToUse as device name prefix"
Write-Host "Will use $PrefixToUse as device name prefix"
}
- $serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
- $computername = Set-ConfiguredComputerName($PrefixToUse + $serial)
+ if (-not $skipPrefixSelection) {
+ $serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
+ $computername = Set-ConfiguredComputerName($PrefixToUse + $serial)
+ }
}
elseif ($Unattend -and $UnattendComputerName) {
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
@@ -1177,17 +1208,7 @@ else {
If ($autopilot -eq $true -and $PPKG -eq $true) {
WriteLog 'Both PPKG and Autopilot json files found'
Write-Host 'Both Autopilot JSON files and Provisioning packages were found.'
- do {
- try {
- $var = $true
- [int]$APorPPKG = Read-Host 'Enter 1 for Autopilot or 2 for Provisioning Package'
- }
-
- catch {
- Write-Host 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package'
- $var = $false
- }
- } until (($APorPPKG -gt 0 -and $APorPPKG -lt 3) -and $var)
+ $APorPPKG = Read-MenuSelection -Prompt 'Enter 1 for Autopilot or 2 for Provisioning Package' -InvalidInputMessage 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package.' -Minimum 1 -Maximum 2
If ($APorPPKG -eq 1) {
$PPKG = $false
}
@@ -1206,22 +1227,20 @@ If ($APFilesCount -gt 1 -and $autopilot -eq $true) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, APFileName
- do {
- try {
- $var = $true
- [int]$APFileSelected = Read-Host 'Enter the AP json file number to install'
- $APFileSelected = $APFileSelected - 1
- }
+ $APFileSelection = Read-MenuSelection -Prompt 'Enter the AP json file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid AP json file number.' -Minimum 1 -Maximum $APFilesCount -AllowSkip
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid AP json file number'
- $var = $false
- }
- } until (($APFileSelected -le $APFilesCount - 1) -and $var)
-
- $APFileToInstall = $array[$APFileSelected].APFile
- $APFileName = $array[$APFileSelected].APFileName
- WriteLog "$APFileToInstall was selected"
+ if ($APFileSelection -eq 0) {
+ $APFileToInstall = $null
+ $APFileName = $null
+ WriteLog 'User chose to skip Autopilot JSON selection.'
+ Write-Host "`nAutopilot JSON selection was skipped."
+ }
+ else {
+ $APFileSelected = $APFileSelection - 1
+ $APFileToInstall = $array[$APFileSelected].APFile
+ $APFileName = $array[$APFileSelected].APFileName
+ WriteLog "$APFileToInstall was selected"
+ }
}
elseif ($APFilesCount -eq 1 -and $autopilot -eq $true) {
WriteLog "Found $APFilesCount AP File"
@@ -1244,22 +1263,19 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, PPKGFileName
- do {
- try {
- $var = $true
- [int]$PPKGFileSelected = Read-Host 'Enter the PPKG file number to install'
- $PPKGFileSelected = $PPKGFileSelected - 1
- }
+ $PPKGFileSelection = Read-MenuSelection -Prompt 'Enter the PPKG file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid PPKG file number.' -Minimum 1 -Maximum $PPKGFilesCount -AllowSkip
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid PPKG file number'
- $var = $false
- }
- } until (($PPKGFileSelected -le $PPKGFilesCount - 1) -and $var)
-
- $PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
- WriteLog "$PPKGFileToInstall was selected"
- Write-Host "`n$PPKGFileToInstall will be used"
+ if ($PPKGFileSelection -eq 0) {
+ $PPKGFileToInstall = $null
+ WriteLog 'User chose to skip Provisioning Package selection.'
+ Write-Host "`nProvisioning Package selection was skipped."
+ }
+ else {
+ $PPKGFileSelected = $PPKGFileSelection - 1
+ $PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
+ WriteLog "$PPKGFileToInstall was selected"
+ Write-Host "`n$PPKGFileToInstall will be used"
+ }
}
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
Write-SectionHeader -Title 'Provisioning Package Selection'
@@ -1375,7 +1391,7 @@ if ($null -eq $DriverSourcePath) {
if ([string]::IsNullOrWhiteSpace($relativeSegment)) {
return Split-Path -Path $normalizedPath -Leaf
}
- return $relativePath = $relativeSegment
+ return $relativeSegment
}
return $normalizedPath
}
@@ -1451,25 +1467,17 @@ if ($null -eq $DriverSourcePath) {
}
}
$displayArray | Format-Table -Property Number, Type, RelativePath -AutoSize
-
+
$DriverSelected = -1
$skipDriverInstall = $false
- do {
- try {
- $var = $true
- [int]$userSelection = Read-Host 'Enter the number of the driver source to install (0 to skip)'
- if ($userSelection -eq 0) {
- $skipDriverInstall = $true
- break
- }
- $DriverSelected = $userSelection - 1
- }
- catch {
- Write-Host 'Input was not in correct format. Please enter a valid number.'
- $var = $false
- }
- } until ((($DriverSelected -ge 0 -and $DriverSelected -lt $DriverSourcesCount) -or $skipDriverInstall) -and $var)
-
+ $userSelection = Read-MenuSelection -Prompt 'Enter the number of the driver source to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid number.' -Minimum 1 -Maximum $DriverSourcesCount -AllowSkip
+ if ($userSelection -eq 0) {
+ $skipDriverInstall = $true
+ }
+ else {
+ $DriverSelected = $userSelection - 1
+ }
+
if ($skipDriverInstall) {
$DriverSourcePath = $null
$DriverSourceType = $null
diff --git a/FFUDevelopment/unattend/SampleUnattend_x64.xml b/FFUDevelopment/unattend/SampleUnattend_x64.xml
index 0a85e64..b601ce5 100644
--- a/FFUDevelopment/unattend/SampleUnattend_x64.xml
+++ b/FFUDevelopment/unattend/SampleUnattend_x64.xml
@@ -3,7 +3,7 @@
- MYCOMPUTER
+ *Eastern Standard Time
diff --git a/FFUDevelopment/unattend/unattend_arm64.xml b/FFUDevelopment/unattend/unattend_arm64.xml
index ae61894..a9c2043 100644
--- a/FFUDevelopment/unattend/unattend_arm64.xml
+++ b/FFUDevelopment/unattend/unattend_arm64.xml
@@ -3,7 +3,7 @@
- MyComputer
+ *
diff --git a/FFUDevelopment/unattend/unattend_x64.xml b/FFUDevelopment/unattend/unattend_x64.xml
index 4023587..4f5014c 100644
--- a/FFUDevelopment/unattend/unattend_x64.xml
+++ b/FFUDevelopment/unattend/unattend_x64.xml
@@ -3,7 +3,7 @@
- MyComputer
+ *
diff --git a/docs/build.md b/docs/build.md
index b19fc83..abaad01 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -121,9 +121,322 @@ Controls the `-BitsPriority` parameter, which determines the priority level for
If you want faster downloads, change the priority to Foreground. Normal priority will significantly slow down downloads since BITS treats non-Foreground downloads as synchronous and queues each download. This means multiple driver or winget application downloads will go much slower than using Foreground. Normal is default as per Microsoft best practice guidance for using BITS.
-## Build USB Drive
+## General Build Options Expander
-The following sub-options control how the USB drive is created
+This expander groups the core build behaviors that affect how the FFU is created, optimized, cached, and prepared for deployment.
+
+### Compact OS
+
+Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
+
+#### How It Works
+
+When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
+
+#### Benefits
+
+| Benefit | Description |
+| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
+| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
+| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
+| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
+
+#### When to Disable
+
+You may want to disable Compact OS in the following scenarios:
+
+- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
+- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
+- **Maximum performance requirements**: In rare cases where every CPU cycle matters
+
+{: .note-title}
+
+> Note
+>
+> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
+
+### Update ADK
+
+Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
+
+#### How It Works
+
+When enabled, the build process performs the following checks before starting:
+
+1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
+2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
+3. **Update if Needed**: If an older version is detected:
+ - Uninstalls the existing Windows ADK
+ - Uninstalls the existing WinPE add-on
+ - Downloads and installs the latest Windows ADK with Deployment Tools feature
+ - Downloads and installs the latest WinPE add-on
+
+#### Features Installed
+
+When installing or updating the ADK, the following features are included:
+
+| Component | Feature ID | Description |
+| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
+| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
+| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
+
+#### Installation Location
+
+The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
+
+#### When to Disable
+
+You may want to disable Update ADK in the following scenarios:
+
+- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
+- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
+- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
+
+{: .warning-title}
+
+> Warning
+>
+> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
+
+#### Manual ADK Installation
+
+If you prefer to manually install the ADK, visit:
+
+[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
+
+You must install both:
+
+- Windows Assessment and Deployment Kit (with Deployment Tools feature)
+- Windows PE add-on for the Windows ADK
+
+### Optimize
+
+Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
+
+- `DISM /Optimize-FFU /ImageFile:`
+
+This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
+
+**Default:** Enabled (`-Optimize $true`)
+
+#### When to Disable
+
+You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
+
+{: .warning-title}
+
+> Warning
+>
+> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
+
+{: .note-title}
+
+> Note
+>
+> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
+
+### Allow VHDX Caching
+
+Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
+
+**Default:** Disabled (`-AllowVHDXCaching $false`)
+
+#### Cache Matching
+
+A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
+
+- Windows release, version, and SKU
+- Logical sector size (512 vs 4096)
+- Optional features selection
+- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
+
+#### Disk Usage and Cleanup
+
+VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
+
+{: .note-title}
+
+> Note
+>
+> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
+
+### Create Deployment Media
+
+Controls the `-CreateDeploymentMedia` parameter.
+
+When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
+
+The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
+
+**Default:** Enabled (`-CreateDeploymentMedia $true`)
+
+{: .note-title}
+
+> Note
+>
+> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
+
+{: .tip-title}
+
+> Tip
+>
+> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
+
+### Inject Unattend.xml
+
+Controls the `-InjectUnattend` parameter. When checked, copies the architecture-specific unattend XML file from `.\FFUDevelopment\unattend` into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
+
+This option is only available when **Install Apps** is checked.
+
+`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
+
+#### How It Works
+
+When enabled, the build process:
+
+1. Determines the correct unattend file based on the target architecture:
+ * **unattend_x64.xml** for x64 builds
+ * **unattend_arm64.xml** for arm64 builds
+2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
+3. Copies the architecture-specific unattend file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
+4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
+
+The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
+
+#### Creating Your Unattend Files
+
+Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend` folder:
+
+| File | Description |
+| ---------------------------- | ----------------------------------- |
+| **unattend_x64.xml** | Unattend file used for x64 builds |
+| **unattend_arm64.xml** | Unattend file used for arm64 builds |
+
+{: .warning-title}
+
+> Important
+>
+> Keep the file names with the architecture suffix (e.g., unattend_x64.xml). The script handles renaming the file to `Unattend.xml` when copying it to the Apps folder.
+
+#### When to Use This Option
+
+This option is primarily intended for scenarios where:
+
+* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
+* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
+
+#### Limitations
+
+| Limitation | Description |
+| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **No prefixes.txt or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt` or the `%serial%` variable for deployment-time device naming |
+| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
+| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
+
+{: .note-title}
+
+> Note
+>
+> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
+
+{: .tip-title}
+
+> Tip
+>
+> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
+
+### Verbose
+
+Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
+
+In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
+
+## Device Naming Expander
+
+Use the **Device Naming** expander to decide whether `ComputerName` should be set at deployment time when unattend is applied. There are some major benefits to doing this:
+
+1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
+2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
+
+### No Device Name
+
+This is the default option. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
+
+### Specify Device Name
+
+Use this option when you want a static device name or a template such as `Comp-%serial%`.
+
+- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
+- With **Inject Unattend.xml**, only static names are supported.
+- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
+
+### Specify a list of Prefixes
+
+This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line in the multiline prefixes box. If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
+
+For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
+
+Sample `prefixes.txt` content:
+
+```plaintext
+CORP-
+STORE-
+KIOSK-
+```
+
+{: .note-title}
+
+> Note
+>
+> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `*`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
+
+### Prefixes File Path
+
+Use **Prefixes File Path** to point the UI at the source text file for the prefixes list. The file can use any name. When you browse to a prefixes file in the UI, or when a saved configuration references a valid prefixes path, the UI loads that file and populates the multiline prefixes box from its contents.
+
+### Save Prefixes
+
+Use **Save Prefixes** to write the current multiline prefixes list back to the file specified in **Prefixes File Path**.
+
+### Legacy Prompt Behavior
+
+Older deployment media that still has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
+
+{: .warning-title}
+
+> Warning
+>
+> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
+
+### Creating Your Unattend Files
+
+The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
+
+| File | Description |
+| -------------------------------- | ------------------------------------------ |
+| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
+| **unattend_x64.xml** | Active unattend file used for x64 builds |
+| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
+| **SamplePrefixes.txt** | Example prefixes file for device naming |
+
+Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, and `prefixes.txt` files.
+
+{: .note-title}
+
+> Note
+>
+> The unattend file must contain a `` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
+
+## Build USB Drive Options Expander
+
+This expander groups the settings used to create deployment USB drives after the FFU and deployment media are ready.
+
+### Build USB Drive
+
+Controls the `-BuildUSBDrive` parameter. When checked, FFU Builder partitions and formats selected USB drives and copies the captured FFU plus the enabled deployment assets to them. The default is **unchecked**.
+
+The remaining settings in this expander apply only when **Build USB Drive** is enabled.
### Allow External Hard Disk Media
@@ -184,7 +497,7 @@ Use the **Select All** checkbox in the column header to quickly select or desele
### Copy Autopilot Profile
-Controls the `-CopyAutopilot` parameter. When checked, copies the contents of the `.\FFUDevelopment\Autopilot` folder to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
+Controls the `-CopyAutopilot` parameter. When checked, copies the contents of `.\FFUDevelopment\Autopilot` to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
@@ -203,73 +516,11 @@ When enabled, the build process copies:
During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
-#### Device Naming
-
-Use the **Device Naming** expander to decide whether `ComputerName` should be set at deployment time. There are some major benefits to doing this:
-
-1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
-2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
-
-#### No Device Name
-
-This is the default option. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
-
-#### Specify Device Name
-
-Use this option when you want a static device name or a template such as `Comp-%serial%`.
-
-- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
-- With **Inject Unattend.xml**, only static names are supported.
-- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
-
-#### Specify a list of Prefixes
-
-This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line or import an existing prefixes file. The source file can use any name because the UI now tracks the path separately.
-
-If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
-
-For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
-
-Sample `prefixes.txt` content:
-
-```plaintext
-CORP-
-STORE-
-KIOSK-
-```
-
-#### Legacy Prompt Behavior
-
-Older deployment media that still has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
-
-{: .warning-title}
-
-> Warning
->
-> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
-
-#### Creating Your Unattend Files
-
-The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
-
-| File | Description |
-| -------------------------------- | ------------------------------------------ |
-| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
-| **unattend_x64.xml** | Active unattend file used for x64 builds |
-| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
-| **SamplePrefixes.txt** | Example prefixes file for device naming |
-
-Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, and `prefixes.txt` files.
-
-{: .note-title}
-
-> Note
->
-> The unattend file must contain a `` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
+See **Device Naming Expander** above for the available computer-name modes and prefixes-file behavior.
### Copy Provisioning Package
-Controls the `-CopyPPKG` parameter. When checked, copies the contents of the `.\FFUDevelopment\PPKG` folder to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
+Controls the `-CopyPPKG` parameter. When checked, copies the contents of `.\FFUDevelopment\PPKG` to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
@@ -333,248 +584,23 @@ This option is only available when **Build USB Drive** is checked.
When building USB drives, the script processes multiple drives concurrently to speed up imaging. This setting controls how many drives are formatted and copied to simultaneously.
-## Compact OS
+## Post-Build Cleanup Expander
-Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
+This expander groups the cleanup settings that run after a successful build completes.
-### How It Works
-
-When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
-
-### Benefits
-
-| Benefit | Description |
-| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
-| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
-| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
-| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
-| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
-
-### When to Disable
-
-You may want to disable Compact OS in the following scenarios:
-
-- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
-- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
-- **Maximum performance requirements**: In rare cases where every CPU cycle matters
-
-{: .note-title}
-
-> Note
->
-> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
-
-## Update ADK
-
-Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
-
-### How It Works
-
-When enabled, the build process performs the following checks before starting:
-
-1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
-2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
-3. **Update if Needed**: If an older version is detected:
- - Uninstalls the existing Windows ADK
- - Uninstalls the existing WinPE add-on
- - Downloads and installs the latest Windows ADK with Deployment Tools feature
- - Downloads and installs the latest WinPE add-on
-
-### Features Installed
-
-When installing or updating the ADK, the following features are included:
-
-| Component | Feature ID | Description |
-| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
-| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
-| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
-
-### Installation Location
-
-The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
-
-### When to Disable
-
-You may want to disable Update ADK in the following scenarios:
-
-- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
-- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
-- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
-
-{: .warning-title}
-
-> Warning
->
-> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
-
-### Manual ADK Installation
-
-If you prefer to manually install the ADK, visit:
-
-[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
-
-You must install both:
-
-- Windows Assessment and Deployment Kit (with Deployment Tools feature)
-- Windows PE add-on for the Windows ADK
-
-## Optimize
-
-Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
-
-- `DISM /Optimize-FFU /ImageFile:`
-
-This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
-
-**Default:** Enabled (`-Optimize $true`)
-
-### When to Disable
-
-You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
-
-{: .warning-title}
-
-> Warning
->
-> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
-
-{: .note-title}
-
-> Note
->
-> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
-
-## Allow VHDX Caching
-
-Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
-
-**Default:** Disabled (`-AllowVHDXCaching $false`)
-
-### Cache Matching
-
-A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
-
-- Windows release, version, and SKU
-- Logical sector size (512 vs 4096)
-- Optional features selection
-- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
-
-### Disk Usage and Cleanup
-
-VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
-
-{: .note-title}
-
-> Note
->
-> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
-
-## Create Deployment Media
-
-Controls the `-CreateDeploymentMedia` parameter.
-
-When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
-
-The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
-
-**Default:** Enabled (`-CreateDeploymentMedia $true`)
-
-{: .note-title}
-
-> Note
->
-> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
-
-{: .tip-title}
-
-> Tip
->
-> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
-
-## Inject Unattend.xml
-
-Controls the `-InjectUnattend` parameter. When checked, copies the architecture-specific unattend XML file from `.\FFUDevelopment\unattend` into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
-
-This option is only available when **Install Apps** is checked.
-
-`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
-
-### How It Works
-
-When enabled, the build process:
-
-1. Determines the correct unattend file based on the target architecture:
- * **unattend_x64.xml** for x64 builds
- * **unattend_arm64.xml** for arm64 builds
-2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
-3. Copies the architecture-specific unattend file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
-4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
-
-The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
-
-### Creating Your Unattend Files
-
-Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend` folder:
-
-| File | Description |
-| ---------------------------- | ----------------------------------- |
-| **unattend_x64.xml** | Unattend file used for x64 builds |
-| **unattend_arm64.xml** | Unattend file used for arm64 builds |
-
-{: .warning-title}
-
-> Important
->
-> Keep the file names with the architecture suffix (e.g., unattend_x64.xml). The script handles renaming the file to `Unattend.xml` when copying it to the Apps folder.
-
-### When to Use This Option
-
-This option is primarily intended for scenarios where:
-
-* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
-* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
-
-### Limitations
-
-| Limitation | Description |
-| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **No prefixes.txt or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt` or the `%serial%` variable for deployment-time device naming |
-| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
-| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
-
-{: .note-title}
-
-> Note
->
-> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
-
-{: .tip-title}
-
-> Tip
->
-> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
-
-## Verbose
-
-Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
-
-In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
-
-# Post-Build Cleanup
-
-## Cleanup Apps ISO
+### Cleanup Apps ISO
Controls the `-CleanupAppsISO` parameter. When checked, the Apps ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
-During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (e.g., `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder—including application installers, Office deployment files, and orchestration scripts—and is mounted to the VM during the build to install applications.
+During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (for example, `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder, including application installers, Office deployment files, and orchestration scripts, and is mounted to the VM during the build to install applications.
-### When to Disable
+#### When to Disable
You may want to disable Cleanup Apps ISO in the following scenarios:
-* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
-* **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
-* **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
+- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
+- **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
+- **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
{: .note-title}
@@ -582,69 +608,69 @@ You may want to disable Cleanup Apps ISO in the following scenarios:
>
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
-## Cleanup Deploy ISO
+### Cleanup Deploy ISO
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
-During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (e.g., `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
+During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (for example, `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
-### When to Disable
+#### When to Disable
You may want to disable Cleanup Deploy ISO in the following scenarios:
-* **Creating deployment media separately**: When you want to create USB deployment drives at a later time, see [USB Imaging Tool Creator](/FFU/usb_imaging_tool_creator.html) for a staged workflow using `USBImagingToolCreator.ps1` with a deploy ISO, `FFU`, and `Drivers` folder (local path or network share).
-* **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
+- **Creating deployment media separately**: When you want to create USB deployment drives at a later time, see [USB Imaging Tool Creator](/FFU/usb_imaging_tool_creator.html) for a staged workflow using `USBImagingToolCreator.ps1` with a deploy ISO, `FFU`, and `Drivers` folder (local path or network share)
+- **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
-## Cleanup Drivers
+### Cleanup Drivers
Controls the `-CleanupDrivers` parameter. When checked, the contents of the Drivers folder are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
-During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (e.g., `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
+During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (for example, `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
-### When to Enable
+#### When to Enable
You may want to enable Cleanup Drivers in the following scenarios:
-* **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
-* **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
-* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
+- **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
+- **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
+- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
-### When to Disable
+#### When to Disable
You may want to keep Cleanup Drivers disabled in the following scenarios:
-* **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
-* **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
-* **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
-* **Bring Your Own Drivers:** When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
+- **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
+- **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
+- **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
+- **Bring Your Own Drivers**: When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
{: .note-title}
> Note
>
-> Only the contents within the Drivers folder are removed—the folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
+> Only the contents within the Drivers folder are removed. The folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
-## Remove FFU
+### Remove FFU
Controls the `-RemoveFFU` parameter. When checked, all FFU files in the FFU Capture Location are automatically deleted after the build completes successfully. The default is **unchecked**.
-During the build process, the captured FFU image is written to the FFU Capture Location (e.g., `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
+During the build process, the captured FFU image is written to the FFU Capture Location (for example, `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
-### When to Enable
+#### When to Enable
You may want to enable Remove FFU in the following scenarios:
-* **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
-* **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
-* **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
+- **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
+- **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
+- **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
-### When to Disable
+#### When to Disable
You may want to keep Remove FFU disabled in the following scenarios:
-* **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
-* **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
-* **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
+- **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
+- **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
+- **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
{: .warning-title}
@@ -652,15 +678,15 @@ You may want to keep Remove FFU disabled in the following scenarios:
>
> This option removes **all** FFU files in the FFU Capture Location folder, not just the FFU from the current build. If you have previously captured FFU files stored in this folder that you want to keep, do not enable this option or move those files to a different location before building.
-## Remove Apps Folder Content
+### Remove Apps Folder Content
-Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **un****checked**.
+Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
-During the build process, application content accumulates in several subfolders within the Apps folder (e.g., `.\FFUDevelopment\Apps`):
+During the build process, application content accumulates in several subfolders within the Apps folder (for example, `.\FFUDevelopment\Apps`):
| Folder | Contents |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
-| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
+| `Win32` | Winget source applications and Bring Your Own Apps content copied using the **Copy Apps** button or manually copied |
| `MSStore` | Microsoft Store applications downloaded via Winget |
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
@@ -668,34 +694,34 @@ Additionally, the `WinGetWin32Apps.json` orchestration file in `.\FFUDevelopment
When this option is enabled, the cleanup process removes:
-* The entire `Win32` folder and its contents
-* The entire `MSStore` folder and its contents
-* The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
+- The entire `Win32` folder and its contents
+- The entire `MSStore` folder and its contents
+- The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
-### When to Enable
+#### When to Enable
You may want to keep Remove Apps Folder Content enabled in the following scenarios:
-* **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
-* **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
-* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
+- **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
+- **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
+- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
-### When to Disable
+#### When to Disable
You may want to disable Remove Apps Folder Content in the following scenarios:
-* **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
-* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
-* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
-* **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
+- **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
+- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
+- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
+- **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
{: .note-title}
> Note
>
-> Only the application content subfolders are removed—the `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
+> Only the application content subfolders are removed. The `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
-## Remove Downloaded Update Files
+### Remove Downloaded Update Files
Controls the `-RemoveUpdates` parameter. When checked, downloaded Windows updates and application update payloads are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
@@ -711,28 +737,60 @@ During the build process, update files are downloaded to specific locations with
When this option is enabled, the cleanup process removes the entire `KB` folder and the specific update subfolders within the `Apps` directory.
-### When to Enable
+#### When to Enable
You may want to keep Remove Downloaded Update Files enabled in the following scenarios:
-* **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
-* **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
+- **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
+- **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
-### When to Disable
+#### When to Disable
You may want to disable Remove Downloaded Update Files in the following scenarios:
-* **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
-* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
-* **Testing and validation**: When you want to manually inspect the update files that were included in the build
+- **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
+- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
+- **Testing and validation**: When you want to manually inspect the update files that were included in the build
{: .note-title}
> Note
>
-> Only the update-specific subfolders are removed-the `Apps` folder itself and other application content (unless **Remove Apps Folder Content** is also selected) are preserved.
+> Only the update-specific subfolders are removed. The `Apps` folder itself and other application content, unless **Remove Apps Folder Content** is also selected, are preserved.
-## Restore Defaults
+### Remove Downloaded ESD file(s)
+
+Controls the `-RemoveDownloadedESD` parameter. When checked, downloaded Windows ESD files are automatically deleted after they have been applied. The default is **checked**.
+
+This setting applies to builds that use downloaded Windows ESD media instead of a provided ISO. When enabled, the build removes the downloaded `.esd` file after it has been used. When disabled, the downloaded `.esd` is kept for reuse on later builds.
+
+#### When to Enable
+
+You may want to keep Remove Downloaded ESD file(s) enabled in the following scenarios:
+
+- **Conserving disk space**: Downloaded ESD files can be large, and removing them after a successful build frees up storage
+- **Ensuring fresh media**: When you want each build to download the latest available ESD for the selected release and version
+- **Single-use builds**: When you do not expect to reuse the same downloaded source media again
+
+#### When to Disable
+
+You may want to disable Remove Downloaded ESD file(s) in the following scenarios:
+
+- **Multiple builds with the same source media**: Keeping the ESD avoids re-downloading it each time
+- **Offline or bandwidth-constrained environments**: Retaining the ESD allows reuse across builds
+- **Troubleshooting source-media issues**: When you want to preserve the downloaded ESD for inspection or repeat testing
+
+{: .note-title}
+
+> Note
+>
+> This option only applies when the build used a downloaded `.esd` file. If you provide a Windows ISO instead, this setting has no effect.
+
+## Build Page Actions
+
+These buttons sit below the Build tab expanders and operate on the overall page state rather than a single expander.
+
+### Restore Defaults
Use this to restore FFU Builder to its default state. When clicked:
@@ -750,7 +808,7 @@ Use this to restore FFU Builder to its default state. When clicked:
>
> If you want to keep any content prior to restoring defaults, copy it out first.
-## Save Config File
+### Save Config File
Saves all current UI selections to a JSON file so you can reload the same settings later or run `BuildFFUVM.ps1` from the command line with `-configFile` (e.g. `BuildFFUVM.ps1 -configFile C:\FFUDevelopment\config\FFUConfig.json`)
@@ -760,7 +818,7 @@ Saves all current UI selections to a JSON file so you can reload the same settin
- Defaults the save location to `FFUDevelopmentPath\config` and suggests `FFUConfig.json` as the file name. You can browse and pick a different file name or folder.
- Creates the `config` folder if it does not exist and confirms the save when finished.
-## Load Config File
+### Load Config File
Loads a previously saved configuration JSON and repopulates the UI.
@@ -771,7 +829,7 @@ Loads a previously saved configuration JSON and repopulates the UI.
- Supplemental files referenced in the config (Winget `AppList.json`, BYO `UserAppList.json`, `Drivers.json`) are also imported if they exist. Missing helper files are treated as optional and noted for you.
- If the file is empty, unreadable, or invalid JSON, the load is stopped and an error message is shown.
-## Build FFU
+### Build FFU
Use **Build FFU** to run `BuildFFUVM.ps1` with the current UI selections.
diff --git a/docs/quickstart.md b/docs/quickstart.md
index c1f5e29..5bd292d 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -207,6 +207,12 @@ Use this option when you want a static device name or a template such as `Comp-%
This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line or import an existing prefixes file. The source file can use any name because the UI tracks the prefixes path separately. If there is one prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one and the serial number is appended to that prefix.
+{: .note-title}
+
+> Note
+>
+> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `*`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
+
{: .warning-title}
> Warning
From 82bac17b38fefd2c3a49cdac6d9aaaaff6dc7133 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 9 Apr 2026 13:33:37 -0700
Subject: [PATCH 21/30] Adds Prompt option to device naming mode
Introduces an explicit "Prompt" option for device naming to allow technicians to enter the device name during deployment. This replaces the implicit legacy behavior, providing clear UI controls and validation to ensure that Unattend.xml is copied, which is required for prompt-based naming. Relevant documentation is updated to reflect this new workflow capability.
---
FFUDevelopment/BuildFFUVM.ps1 | 21 ++++++++++++++-----
FFUDevelopment/BuildFFUVM_UI.ps1 | 10 ++++++++-
FFUDevelopment/BuildFFUVM_UI.xaml | 1 +
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 5 ++++-
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 20 ++++++++++++++----
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 1 +
docs/build.md | 15 +++++++++++--
docs/parameters_reference.md | 2 +-
docs/quickstart.md | 8 +++++--
9 files changed, 67 insertions(+), 16 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index bb61540..ba73768 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -73,7 +73,7 @@ When set to $true, will copy the provisioning package from the $FFUDevelopmentPa
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.
+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, 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.
@@ -419,7 +419,7 @@ param(
[bool]$AllowVHDXCaching,
[bool]$CopyPPKG,
[bool]$CopyUnattend,
- [ValidateSet('Legacy', 'None', 'Template', 'Prefixes')]
+ [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')]
[string]$DeviceNamingMode = 'Legacy',
[string]$DeviceNameTemplate,
[string[]]$DeviceNamePrefixes,
@@ -557,7 +557,7 @@ function Save-StagedUnattendFile {
[Parameter(Mandatory = $true)]
[string]$DestinationPath,
[Parameter(Mandatory = $true)]
- [ValidateSet('Legacy', 'None', 'Template', 'Prefixes')]
+ [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')]
[string]$DeviceNamingMode,
[string]$DeviceNameTemplate
)
@@ -588,6 +588,9 @@ function Save-StagedUnattendFile {
if ($DeviceNamingMode -eq 'None') {
$computerNameComponent.ComputerName = '*'
}
+ elseif ($DeviceNamingMode -eq 'Prompt') {
+ $computerNameComponent.ComputerName = 'MyComputer'
+ }
elseif ($DeviceNamingMode -eq 'Template') {
$computerNameComponent.ComputerName = $DeviceNameTemplate
}
@@ -638,6 +641,11 @@ if ($DeviceNamingMode -eq 'Template') {
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.'
@@ -4364,6 +4372,9 @@ Function New-DeploymentUSB {
if ($DeviceNamingMode -eq 'None') {
$computerNameComponent.ComputerName = '*'
}
+ elseif ($DeviceNamingMode -eq 'Prompt') {
+ $computerNameComponent.ComputerName = 'MyComputer'
+ }
elseif ($DeviceNamingMode -eq 'Template') {
$computerNameComponent.ComputerName = $DeviceNameTemplate
}
@@ -5706,10 +5717,10 @@ if ($CopyUnattend) {
throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file"
}
- if ($DeviceNamingMode -eq 'Prefixes') {
+ if ($DeviceNamingMode -in @('Prompt', 'Prefixes')) {
$unattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch
if (-not (Test-UnattendHasComputerNameElement -Path $unattendSourcePath)) {
- throw "DeviceNamingMode Prefixes requires a ComputerName element in $unattendSourcePath"
+ throw "DeviceNamingMode $DeviceNamingMode requires a ComputerName element in $unattendSourcePath"
}
}
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index 90b8e32..b0771cc 100644
--- a/FFUDevelopment/BuildFFUVM_UI.ps1
+++ b/FFUDevelopment/BuildFFUVM_UI.ps1
@@ -439,7 +439,15 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
- if ($config.DeviceNamingMode -eq 'Template') {
+ 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
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 7ed2603..038e261 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -910,6 +910,7 @@
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index 1d92654..f31c69f 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -472,7 +472,10 @@ function Update-UIFromConfig {
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
$deviceNamingMode = [string]$ConfigContent.DeviceNamingMode
}
- if ($deviceNamingMode -notin @('None', 'Template', 'Prefixes')) {
+ if ($deviceNamingMode -eq 'Legacy') {
+ $deviceNamingMode = 'Prompt'
+ }
+ if ($deviceNamingMode -notin @('None', 'Prompt', 'Template', 'Prefixes')) {
$deviceNamingMode = 'None'
}
Set-DeviceNamingMode -State $State -Mode $deviceNamingMode
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 5fa99e3..0fe70fc 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -30,6 +30,10 @@ function Update-VMNetworkingControls {
function Get-SelectedDeviceNamingMode {
param([PSCustomObject]$State)
+ if ($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) {
+ return 'Prompt'
+ }
+
if ($true -eq $State.Controls.rbDeviceNamingTemplate.IsChecked) {
return 'Template'
}
@@ -44,11 +48,12 @@ function Get-SelectedDeviceNamingMode {
function Set-DeviceNamingMode {
param(
[PSCustomObject]$State,
- [ValidateSet('None', 'Template', 'Prefixes')]
+ [ValidateSet('None', 'Prompt', 'Template', 'Prefixes')]
[string]$Mode
)
$State.Controls.rbDeviceNamingNone.IsChecked = $Mode -eq 'None'
+ $State.Controls.rbDeviceNamingPrompt.IsChecked = $Mode -eq 'Prompt'
$State.Controls.rbDeviceNamingTemplate.IsChecked = $Mode -eq 'Template'
$State.Controls.rbDeviceNamingPrefixes.IsChecked = $Mode -eq 'Prefixes'
}
@@ -132,9 +137,10 @@ function Update-UnattendSelectionControls {
$isCopyUnattendSelected = $true -eq $State.Controls.chkCopyUnattend.IsChecked
$isInjectUnattendSelected = $true -eq $State.Controls.chkInjectUnattend.IsChecked
$deviceNameTemplateUsesSerialToken = Test-DeviceNameTemplateUsesSerialToken -State $State
+ $requiresCopiedUnattend = ($selectedDeviceNamingMode -in @('Prompt', 'Prefixes')) -or $deviceNameTemplateUsesSerialToken
if ($isCopyUnattendSelected -and $isInjectUnattendSelected) {
- if (($selectedDeviceNamingMode -eq 'Prefixes') -or $deviceNameTemplateUsesSerialToken) {
+ if ($requiresCopiedUnattend) {
$State.Controls.chkInjectUnattend.IsChecked = $false
$isInjectUnattendSelected = $false
}
@@ -144,7 +150,7 @@ function Update-UnattendSelectionControls {
}
}
- if (($selectedDeviceNamingMode -eq 'Prefixes') -or $deviceNameTemplateUsesSerialToken) {
+ if ($requiresCopiedUnattend) {
if (-not $isCopyUnattendSelected) {
$State.Controls.chkCopyUnattend.IsChecked = $true
$isCopyUnattendSelected = $true
@@ -177,13 +183,14 @@ function Update-UnattendSelectionControls {
function Update-DeviceNamingControls {
param([PSCustomObject]$State)
- if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked)) {
+ if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and (($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked))) {
$State.Controls.rbDeviceNamingNone.IsChecked = $true
}
$selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
$State.Controls.deviceNameTemplatePanel.Visibility = if ($selectedDeviceNamingMode -eq 'Template') { 'Visible' } else { 'Collapsed' }
$State.Controls.deviceNamePrefixesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'Prefixes') { 'Visible' } else { 'Collapsed' }
+ $State.Controls.rbDeviceNamingPrompt.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
$State.Controls.rbDeviceNamingPrefixes.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
if ($selectedDeviceNamingMode -eq 'Prefixes') {
@@ -435,6 +442,11 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource)
Update-DeviceNamingControls -State $window.Tag
})
+ $State.Controls.rbDeviceNamingPrompt.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ Update-DeviceNamingControls -State $window.Tag
+ })
$State.Controls.rbDeviceNamingTemplate.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index e84e1f5..62cac40 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -221,6 +221,7 @@ function Initialize-UIControls {
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
+ $State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
$State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
$State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
diff --git a/docs/build.md b/docs/build.md
index abaad01..3c5849a 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -363,6 +363,17 @@ Use the **Device Naming** expander to decide whether `ComputerName` should be se
This is the default option. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
+The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `*` for this default behavior.
+
+### Prompt for Device Name
+
+Use this option when you want the technician to enter the computer name during deployment.
+
+- This option requires **Copy Unattend.xml**.
+- The source `unattend_*.xml` files can stay at `*`.
+- During the build, FFU Builder rewrites only the staged deployment copy of `Unattend.xml` to the legacy prompt placeholder that `ApplyFFU.ps1` already recognizes.
+- **Inject Unattend.xml** is not supported with this option.
+
### Specify Device Name
Use this option when you want a static device name or a template such as `Comp-%serial%`.
@@ -399,9 +410,9 @@ Use **Prefixes File Path** to point the UI at the source text file for the prefi
Use **Save Prefixes** to write the current multiline prefixes list back to the file specified in **Prefixes File Path**.
-### Legacy Prompt Behavior
+### Deployment Prompt Compatibility
-Older deployment media that still has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
+Older deployment media that already has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
{: .warning-title}
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index 67912f0..661c20b 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -41,7 +41,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
-| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Template, and Prefixes. The UI uses None, Template, and Prefixes. |
+| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, and Prefixes. The UI uses None, Prompt, Template, and Prefixes. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 5bd292d..8ed8fe7 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -286,18 +286,22 @@ And the Unattend folder should have an unattend.xml file with the following cont
- MyComputer
+ *
```
+Keep `*` if you want Windows to generate a random device name by default.
+
+If you want the technician to be prompted for the device name during deployment, select **Prompt for Device Name** in the Build tab and enable **Copy Unattend.xml**. FFU Builder will rewrite only the staged deployment copy of `Unattend.xml` for that workflow.
+
Now you're ready to deploy the FFU to your device.
## Deployment
-Deployment should be fairly straight forward: boot off the USB device, get prompted for a device name, and the deployment of the FFU and drivers should happen automatically.
+Deployment should be fairly straight forward: boot off the USB device and the deployment of the FFU and drivers should happen automatically. If you selected **Prompt for Device Name** or another supported device naming option, that naming step will happen during deployment.
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
From f1f1957c4352ee397683ad580066b223b0850ebd Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 9 Apr 2026 15:14:00 -0700
Subject: [PATCH 22/30] Auto-generate ComputerName in Unattend XML
Replaces the strict validation for an existing ComputerName element with dynamic XML initialization. Automatically creates the specialize settings block, the Microsoft-Windows-Shell-Setup component, and the ComputerName element if they are missing from the provided Unattend XML file. This improves script robustness and simplifies the requirements for custom unattended setup templates by patching in the necessary device naming structure on the fly.
---
FFUDevelopment/BuildFFUVM.ps1 | 272 ++++++++++++++++++++++++++--------
1 file changed, 213 insertions(+), 59 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index ba73768..dfe1b92 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -534,20 +534,79 @@ function Get-UnattendSourcePath {
return Join-Path $UnattendFolder "unattend_$archSuffix.xml"
}
-function Test-UnattendHasComputerNameElement {
+function Initialize-UnattendComputerNamePath {
param(
[Parameter(Mandatory = $true)]
- [string]$Path
+ [xml]$UnattendXml,
+ [Parameter(Mandatory = $true)]
+ [string]$WindowsArch
)
- [xml]$unattendXml = Get-Content -Path $Path
- foreach ($component in $unattendXml.unattend.settings.component) {
- if ($component.ComputerName) {
- return $true
- }
+ $unattendRoot = $UnattendXml.DocumentElement
+ if (($null -eq $unattendRoot) -or ($unattendRoot.LocalName -ne 'unattend')) {
+ throw 'Unattend XML is missing the unattend root element.'
}
- return $false
+ $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 {
@@ -559,40 +618,47 @@ function Save-StagedUnattendFile {
[Parameter(Mandatory = $true)]
[ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')]
[string]$DeviceNamingMode,
- [string]$DeviceNameTemplate
+ [string]$DeviceNameTemplate,
+ [Parameter(Mandatory = $true)]
+ [string]$WindowsArch,
+ [bool]$LegacyPrefixesWillBeStaged = $false
)
- if ($DeviceNamingMode -in @('Legacy', 'Prefixes')) {
+ if ($DeviceNamingMode -eq 'None') {
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
+ $computerNamePath = Initialize-UnattendComputerNamePath -UnattendXml $unattendXml -WindowsArch $WindowsArch
+
+ if ($computerNamePath.CreatedSpecializeSettings -or $computerNamePath.CreatedShellSetupComponent -or $computerNamePath.CreatedComputerNameElement) {
+ $createdParts = @()
+ if ($computerNamePath.CreatedSpecializeSettings) {
+ $createdParts += 'specialize settings'
}
- }
-
- if ($null -eq $computerNameComponent) {
- if ($DeviceNamingMode -eq 'None') {
- Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null
- return
+ if ($computerNamePath.CreatedShellSetupComponent) {
+ $createdParts += 'Microsoft-Windows-Shell-Setup component'
}
-
- throw "ComputerName element not found in unattend source file: $SourcePath"
+ if ($computerNamePath.CreatedComputerNameElement) {
+ $createdParts += 'ComputerName element'
+ }
+ WriteLog "Created $($createdParts -join ', ') while staging unattend file $DestinationPath"
}
- if ($DeviceNamingMode -eq 'None') {
- $computerNameComponent.ComputerName = '*'
- }
- elseif ($DeviceNamingMode -eq 'Prompt') {
- $computerNameComponent.ComputerName = 'MyComputer'
+ if ($DeviceNamingMode -eq 'Prompt') {
+ $computerNamePath.ComputerNameElement.InnerText = 'MyComputer'
}
elseif ($DeviceNamingMode -eq 'Template') {
- $computerNameComponent.ComputerName = $DeviceNameTemplate
+ $computerNamePath.ComputerNameElement.InnerText = $DeviceNameTemplate
+ }
+ elseif ($DeviceNamingMode -eq 'Prefixes') {
+ if ($computerNamePath.CreatedComputerNameElement) {
+ $computerNamePath.ComputerNameElement.InnerText = '*'
+ }
+ }
+ elseif (($DeviceNamingMode -eq 'Legacy') -and $computerNamePath.CreatedComputerNameElement) {
+ $computerNamePath.ComputerNameElement.InnerText = if ($LegacyPrefixesWillBeStaged) { '*' } else { 'MyComputer' }
}
$unattendXml.Save($DestinationPath)
@@ -4338,45 +4404,124 @@ Function New-DeploymentUSB {
return Join-Path $UnattendFolder "unattend_$archSuffix.xml"
}
+ 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]$DeviceNameTemplate,
+ [string]$WindowsArch,
+ [bool]$LegacyPrefixesWillBeStaged = $false
)
- if ($DeviceNamingMode -in @('Legacy', 'Prefixes')) {
+ if ($DeviceNamingMode -eq 'None') {
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
+ $computerNamePath = Initialize-UnattendComputerNamePath -UnattendXml $unattendXml -WindowsArch $WindowsArch
+
+ if ($computerNamePath.CreatedSpecializeSettings -or $computerNamePath.CreatedShellSetupComponent -or $computerNamePath.CreatedComputerNameElement) {
+ $createdParts = @()
+ if ($computerNamePath.CreatedSpecializeSettings) {
+ $createdParts += 'specialize settings'
}
- }
-
- if ($null -eq $computerNameComponent) {
- if ($DeviceNamingMode -eq 'None') {
- Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null
- return
+ if ($computerNamePath.CreatedShellSetupComponent) {
+ $createdParts += 'Microsoft-Windows-Shell-Setup component'
}
-
- throw "ComputerName element not found in unattend source file: $SourcePath"
+ if ($computerNamePath.CreatedComputerNameElement) {
+ $createdParts += 'ComputerName element'
+ }
+ WriteLog "Created $($createdParts -join ', ') while staging unattend file $DestinationPath"
}
- if ($DeviceNamingMode -eq 'None') {
- $computerNameComponent.ComputerName = '*'
- }
- elseif ($DeviceNamingMode -eq 'Prompt') {
- $computerNameComponent.ComputerName = 'MyComputer'
+ if ($DeviceNamingMode -eq 'Prompt') {
+ $computerNamePath.ComputerNameElement.InnerText = 'MyComputer'
}
elseif ($DeviceNamingMode -eq 'Template') {
- $computerNameComponent.ComputerName = $DeviceNameTemplate
+ $computerNamePath.ComputerNameElement.InnerText = $DeviceNameTemplate
+ }
+ elseif ($DeviceNamingMode -eq 'Prefixes') {
+ if ($computerNamePath.CreatedComputerNameElement) {
+ $computerNamePath.ComputerNameElement.InnerText = '*'
+ }
+ }
+ elseif (($DeviceNamingMode -eq 'Legacy') -and $computerNamePath.CreatedComputerNameElement) {
+ $computerNamePath.ComputerNameElement.InnerText = if ($LegacyPrefixesWillBeStaged) { '*' } else { 'MyComputer' }
}
$unattendXml.Save($DestinationPath)
@@ -4443,12 +4588,13 @@ Function New-DeploymentUSB {
WriteLog "Copying unattend file to $UnattendPathOnUSB"
New-Item -Path $UnattendPathOnUSB -ItemType Directory -ErrorAction SilentlyContinue | 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
+ $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:DeviceNamingMode -eq 'Legacy') -and (Test-Path -Path $using:resolvedDeviceNamePrefixesPath -PathType Leaf)) {
+ elseif ($legacyPrefixesWillBeStaged) {
WriteLog "Copying prefixes.txt file to $UnattendPathOnUSB"
Copy-Item -Path $using:resolvedDeviceNamePrefixesPath -Destination (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Force | Out-Null
}
@@ -5717,21 +5863,29 @@ if ($CopyUnattend) {
throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file"
}
- if ($DeviceNamingMode -in @('Prompt', 'Prefixes')) {
+ if ($DeviceNamingMode -ne 'None') {
$unattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch
- if (-not (Test-UnattendHasComputerNameElement -Path $unattendSourcePath)) {
- throw "DeviceNamingMode $DeviceNamingMode requires a ComputerName element in $unattendSourcePath"
+ 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'
}
-if ($InjectUnattend -and $DeviceNamingMode -eq 'Template') {
+if ($InjectUnattend -and ($DeviceNamingMode -ne 'None')) {
$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"
+ try {
+ [xml]$validationUnattendXml = Get-Content -Path $injectUnattendSourcePath
+ $null = Initialize-UnattendComputerNamePath -UnattendXml $validationUnattendXml -WindowsArch $WindowsArch
+ }
+ catch {
+ throw "DeviceNamingMode $DeviceNamingMode requires a valid specialize/Microsoft-Windows-Shell-Setup/ComputerName path in $injectUnattendSourcePath. $($_.Exception.Message)"
}
}
}
@@ -6644,7 +6798,7 @@ if ($InstallApps) {
# Copy if source exists; otherwise log and skip
if (Test-Path -Path $unattendSource -PathType Leaf) {
$destination = Join-Path $targetFolder 'Unattend.xml'
- Save-StagedUnattendFile -SourcePath $unattendSource -DestinationPath $destination -DeviceNamingMode $DeviceNamingMode -DeviceNameTemplate $normalizedDeviceNameTemplate
+ Save-StagedUnattendFile -SourcePath $unattendSource -DestinationPath $destination -DeviceNamingMode $DeviceNamingMode -DeviceNameTemplate $normalizedDeviceNameTemplate -WindowsArch $WindowsArch
WriteLog "Injected unattend file into Apps: $unattendSource -> $destination"
}
else {
From 7bd5decc627a925527b0b8ba9559d73bddcb0f76 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 9 Apr 2026 16:44:39 -0700
Subject: [PATCH 23/30] Adds custom unattend XML file selection paths
Adds parameters for x64 and arm64 unattend XML file paths.
Updates the BuildFFUVM_UI to let the user pick specific XML files to copy or inject instead of requiring them to exist in the `unattend` folder.
Adds validation to ensure the selected unattend XML files are not empty.
Updates documentation to reflect the new functionality.
---
FFUDevelopment/BuildFFUVM.ps1 | 105 ++++++++++++------
FFUDevelopment/BuildFFUVM_UI.ps1 | 32 ++++++
FFUDevelopment/BuildFFUVM_UI.xaml | 49 ++++++--
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 12 ++
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 67 +++++++++++
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 6 +
FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 4 +
FFUDevelopment/config/Sample_default.json | Bin 6506 -> 6826 bytes
docs/build.md | 34 ++++--
docs/parameters_reference.md | 6 +-
10 files changed, 258 insertions(+), 57 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index dfe1b92..305f56c 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -70,7 +70,7 @@ 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, and Prefixes.
@@ -84,6 +84,12 @@ Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a l
.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 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.
@@ -118,7 +124,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.
@@ -424,6 +430,8 @@ param(
[string]$DeviceNameTemplate,
[string[]]$DeviceNamePrefixes,
[string]$DeviceNamePrefixesPath,
+ [string]$UnattendX64FilePath,
+ [string]$UnattendArm64FilePath,
[bool]$CopyAutopilot,
[bool]$CompactOS = $true,
[bool]$CleanupDeployISO = $true,
@@ -527,11 +535,31 @@ function Get-UnattendSourcePath {
[Parameter(Mandatory = $true)]
[string]$UnattendFolder,
[Parameter(Mandatory = $true)]
- [string]$WindowsArch
+ [string]$WindowsArch,
+ [string]$UnattendX64FilePath,
+ [string]$UnattendArm64FilePath
)
- $archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
- return Join-Path $UnattendFolder "unattend_$archSuffix.xml"
+ $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 {
@@ -915,6 +943,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" }
@@ -4397,11 +4427,31 @@ Function New-DeploymentUSB {
function Get-LocalUnattendSourcePath {
param(
[string]$UnattendFolder,
- [string]$WindowsArch
+ [string]$WindowsArch,
+ [string]$UnattendX64FilePath,
+ [string]$UnattendArm64FilePath
)
- $archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
- return Join-Path $UnattendFolder "unattend_$archSuffix.xml"
+ $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 {
@@ -4587,7 +4637,7 @@ Function New-DeploymentUSB {
$UnattendPathOnUSB = Join-Path $DeployPartitionDriveLetter "Unattend"
WriteLog "Copying unattend file to $UnattendPathOnUSB"
New-Item -Path $UnattendPathOnUSB -ItemType Directory -ErrorAction SilentlyContinue | Out-Null
- $unattendSource = Get-LocalUnattendSourcePath -UnattendFolder $using:UnattendFolder -WindowsArch $using:WindowsArch
+ $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') {
@@ -5850,21 +5900,23 @@ 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') {
- $unattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch
try {
[xml]$validationUnattendXml = Get-Content -Path $unattendSourcePath
$null = Initialize-UnattendComputerNamePath -UnattendXml $validationUnattendXml -WindowsArch $WindowsArch
@@ -5877,19 +5929,6 @@ if ($CopyUnattend) {
WriteLog 'Unattend validation complete'
}
-if ($InjectUnattend -and ($DeviceNamingMode -ne 'None')) {
- $injectUnattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch
- if (Test-Path -Path $injectUnattendSourcePath -PathType Leaf) {
- try {
- [xml]$validationUnattendXml = Get-Content -Path $injectUnattendSourcePath
- $null = Initialize-UnattendComputerNamePath -UnattendXml $validationUnattendXml -WindowsArch $WindowsArch
- }
- catch {
- throw "DeviceNamingMode $DeviceNamingMode requires a valid specialize/Microsoft-Windows-Shell-Setup/ComputerName path in $injectUnattendSourcePath. $($_.Exception.Message)"
- }
- }
-}
-
#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.
@@ -6787,7 +6826,7 @@ if ($InstallApps) {
#Create Apps ISO
# Inject Unattend.xml into Apps if requested and applicable
if ($InstallApps -and $InjectUnattend) {
- $unattendSource = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch
+ $unattendSource = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch -UnattendX64FilePath $UnattendX64FilePath -UnattendArm64FilePath $UnattendArm64FilePath
# Ensure target folder exists under Apps
$targetFolder = Join-Path $AppsPath 'Unattend'
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index b0771cc..6f2c462 100644
--- a/FFUDevelopment/BuildFFUVM_UI.ps1
+++ b/FFUDevelopment/BuildFFUVM_UI.ps1
@@ -439,6 +439,38 @@ $script:uiState.Controls.btnRun.Add_Click({
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
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 038e261..e1977cb 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -836,11 +836,13 @@
-
+
-
+
-
+
+
+
@@ -900,13 +902,39 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -938,8 +966,8 @@
-
-
+
+
@@ -947,7 +975,6 @@
-
@@ -1004,8 +1031,8 @@
-
-
+
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index f31c69f..f9e24de 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -43,6 +43,8 @@ function Get-UIConfig {
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
+ UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
+ UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
@@ -454,8 +456,18 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
+ Set-UIValue -ControlName 'txtUnattendX64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendX64FilePath' -State $State
+ Set-UIValue -ControlName 'txtUnattendArm64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendArm64FilePath' -State $State
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
+ if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendX64FilePath.Text)) {
+ $State.Controls.txtUnattendX64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
+ }
+
+ if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendArm64FilePath.Text)) {
+ $State.Controls.txtUnattendArm64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
+ }
+
# USB Drive Modification group (Build Tab)
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 0fe70fc..37a0c96 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -101,6 +101,21 @@ function Get-DefaultDeviceNamePrefixesPath {
return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'prefixes.txt'
}
+function Get-DefaultUnattendFilePath {
+ param(
+ [string]$FFUDevelopmentPath,
+ [ValidateSet('x64', 'arm64')]
+ [string]$WindowsArch
+ )
+
+ if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
+ return $null
+ }
+
+ $fileName = if ($WindowsArch -ieq 'arm64') { 'unattend_arm64.xml' } else { 'unattend_x64.xml' }
+ return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') $fileName
+}
+
function Import-DeviceNamePrefixesFromConfiguredPath {
param(
[PSCustomObject]$State,
@@ -416,12 +431,24 @@ function Register-EventHandlers {
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) {
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
+ $currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
+ $currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
$previousDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ $previousDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
+ $previousDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
$localState.Controls.txtFFUDevPath.Text = $selectedPath
$newDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $selectedPath
+ $newDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'x64'
+ $newDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'arm64'
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath) -or $currentPrefixesPath -ieq $previousDefaultPrefixesPath) {
$localState.Controls.txtDeviceNamePrefixesPath.Text = $newDefaultPrefixesPath
}
+ if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath) -or $currentUnattendX64FilePath -ieq $previousDefaultUnattendX64FilePath) {
+ $localState.Controls.txtUnattendX64FilePath.Text = $newDefaultUnattendX64FilePath
+ }
+ if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath) -or $currentUnattendArm64FilePath -ieq $previousDefaultUnattendArm64FilePath) {
+ $localState.Controls.txtUnattendArm64FilePath.Text = $newDefaultUnattendArm64FilePath
+ }
Import-DeviceNamePrefixesFromConfiguredPath -State $localState
Update-DeviceNamingControls -State $localState
}
@@ -484,6 +511,46 @@ function Register-EventHandlers {
Update-DeviceNamingControls -State $localState
}
})
+ $State.Controls.btnBrowseUnattendX64FilePath.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
+ if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) {
+ $currentUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
+ }
+ $initialDirectory = if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) {
+ $null
+ }
+ else {
+ Split-Path $currentUnattendX64FilePath -Parent
+ }
+ $fileName = if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) { 'unattend_x64.xml' } else { Split-Path $currentUnattendX64FilePath -Leaf }
+ $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select x64 unattend XML file' -Filter 'XML files (*.xml)|*.xml|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
+ if (-not [string]::IsNullOrWhiteSpace($selectedPath)) {
+ $localState.Controls.txtUnattendX64FilePath.Text = $selectedPath
+ }
+ })
+ $State.Controls.btnBrowseUnattendArm64FilePath.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
+ if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) {
+ $currentUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
+ }
+ $initialDirectory = if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) {
+ $null
+ }
+ else {
+ Split-Path $currentUnattendArm64FilePath -Parent
+ }
+ $fileName = if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) { 'unattend_arm64.xml' } else { Split-Path $currentUnattendArm64FilePath -Leaf }
+ $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select arm64 unattend XML file' -Filter 'XML files (*.xml)|*.xml|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
+ if (-not [string]::IsNullOrWhiteSpace($selectedPath)) {
+ $localState.Controls.txtUnattendArm64FilePath.Text = $selectedPath
+ }
+ })
$State.Controls.btnSaveDeviceNamePrefixes.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 62cac40..62dcdc0 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -220,6 +220,10 @@ function Initialize-UIControls {
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
+ $State.Controls.txtUnattendX64FilePath = $window.FindName('txtUnattendX64FilePath')
+ $State.Controls.btnBrowseUnattendX64FilePath = $window.FindName('btnBrowseUnattendX64FilePath')
+ $State.Controls.txtUnattendArm64FilePath = $window.FindName('txtUnattendArm64FilePath')
+ $State.Controls.btnBrowseUnattendArm64FilePath = $window.FindName('btnBrowseUnattendArm64FilePath')
$State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
$State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
@@ -387,6 +391,8 @@ function Initialize-UIDefaults {
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
+ $State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
+ $State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index a841359..8957f4a 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -112,6 +112,8 @@ function Get-GeneralDefaults {
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
$deviceNamePrefixesPath = Join-Path -Path $unattendPath -ChildPath "prefixes.txt"
+ $unattendX64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_x64.xml"
+ $unattendArm64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_arm64.xml"
return [PSCustomObject]@{
# Build Tab Defaults
@@ -134,6 +136,8 @@ function Get-GeneralDefaults {
CopyUnattend = $false
CopyPPKG = $false
InjectUnattend = $false
+ UnattendX64FilePath = $unattendX64FilePath
+ UnattendArm64FilePath = $unattendArm64FilePath
DeviceNamingMode = 'None'
DeviceNameTemplate = ''
DeviceNamePrefixesPath = $deviceNamePrefixesPath
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index af3cdb33d447449dc337a675b20442b2136b4d21..dcc97ea59f3b218479d5eb387e6e203bfa03be38 100644
GIT binary patch
delta 170
zcmaE5w90hDG8WlThCGHuh7ur51+r2YA{fjVOc>l4G8u9hQYWsroZQ4JB43K8D4wAL
stV|C`=Q89>Ze$i#L^sHhp$I64X_zvmVTo|1NCr+mz$&zP2g@cA0At)I_W%F@
delta 15
XcmZ2w`pRg-GL}i~LYsfEZV~|iH)sZT
diff --git a/docs/build.md b/docs/build.md
index 3c5849a..4e92747 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -283,11 +283,23 @@ The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_D
>
> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
+## Unattend.xml Options Expander
+
+Use the **Unattend.xml Options** expander to choose how unattend content is staged and which source XML file FFU Builder should use for x64 and arm64 builds.
+
+### x64 Unattend File Path
+
+Use **x64 Unattend File Path** to browse to the source XML file for x64 builds. The default path is `.\FFUDevelopment\unattend\unattend_x64.xml`.
+
+### arm64 Unattend File Path
+
+Use **arm64 Unattend File Path** to browse to the source XML file for arm64 builds. The default path is `.\FFUDevelopment\unattend\unattend_arm64.xml`.
+
### Inject Unattend.xml
-Controls the `-InjectUnattend` parameter. When checked, copies the architecture-specific unattend XML file from `.\FFUDevelopment\unattend` into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
+Controls the `-InjectUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
-This option is only available when **Install Apps** is checked.
+This option is used only when **Install Apps** is checked.
`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
@@ -295,18 +307,16 @@ This option is only available when **Install Apps** is checked.
When enabled, the build process:
-1. Determines the correct unattend file based on the target architecture:
- * **unattend_x64.xml** for x64 builds
- * **unattend_arm64.xml** for arm64 builds
+1. Uses the x64 or arm64 source file selected in **Unattend.xml Options** for the current build architecture
2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
-3. Copies the architecture-specific unattend file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
+3. Copies that file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
#### Creating Your Unattend Files
-Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend` folder:
+You can keep the default architecture-specific files in the `.\FFUDevelopment\unattend` folder or browse to another XML file in the UI:
| File | Description |
| ---------------------------- | ----------------------------------- |
@@ -317,7 +327,7 @@ Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend
> Important
>
-> Keep the file names with the architecture suffix (e.g., unattend_x64.xml). The script handles renaming the file to `Unattend.xml` when copying it to the Apps folder.
+> The default paths use the architecture suffix file names shown above. FFU Builder still renames the selected file to `Unattend.xml` when it stages it into the Apps folder.
#### When to Use This Option
@@ -516,15 +526,17 @@ This leverages the Autopilot for existing devices json file. It's not recommende
### Copy Unattend.xml
-Controls the `-CopyUnattend` parameter. When checked, copies the architecture-appropriate unattend XML file from `.\FFUDevelopment\Unattend` to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
+Controls the `-CopyUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
-This option is only available when **Build USB Drive** is checked.
+Use this option when you plan to build deployment USB media.
When enabled, the build process copies:
-- **unattend_x64.xml** (for x64 builds) or **unattend_arm64.xml** (for arm64 builds) → renamed to **Unattend.xml** on the USB drive
+- The selected x64 or arm64 unattend XML file → renamed to **Unattend.xml** on the USB drive
- **prefixes.txt** → created from the **Device Naming** prefixes list when that mode is selected
+If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
+
During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
See **Device Naming Expander** above for the available computer-name modes and prefixes-file behavior.
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index 661c20b..ec31da0 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -38,12 +38,14 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyDrivers | bool | Copy Drivers to USB drive | When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. |
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
| -CopyPPKG | bool | Copy Provisioning Package | 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. |
-| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
+| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, and Prefixes. The UI uses None, Prompt, Template, and Prefixes. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
+| -UnattendX64FilePath | string | x64 Unattend File Path | Path to the x64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. |
+| -UnattendArm64FilePath | string | arm64 Unattend File Path | Path to the arm64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. |
| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. |
| -DriversFolder | string | Drivers Folder | Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. |
@@ -54,7 +56,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -FFUDevelopmentPath | string | FFU Development Path | Path to the FFU development folder. Default is $PSScriptRoot. |
| -FFUPrefix | string | VM Name Prefix | Prefix for the generated FFU file. Default is _FFU. |
| -Headers | hashtable | CLI only (no UI control) | Headers to use when downloading files. Not recommended to modify. |
-| -InjectUnattend | bool | Inject Unattend.xml | 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. Cannot be used together with -CopyUnattend. Default is $false. |
+| -InjectUnattend | bool | Inject Unattend.xml | 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. Cannot be used together with -CopyUnattend. Default is $false. |
| -InstallApps | bool | Install Applications | 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. |
| -InstallDrivers | bool | Install Drivers to FFU | Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU. |
| -InstallOffice | bool | Install Office | Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM. |
From 28297f3a0e43691e26063b3d6bf62d484265010d Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Tue, 14 Apr 2026 14:56:02 -0700
Subject: [PATCH 24/30] Switches to Windows ADK DISM CLI for adding Windows
packages
Replaces the native PowerShell command for adding Windows packages with direct command-line invocations of the Deployment Image Servicing and Management (DISM) tool. This fixes an issue introduced by the March 31, 2026 OOB update that persists in the April 2026 2B update where Add-WindowsPackage fails due to issues with CBS. Using native dism fixes the issue.
---
FFUDevelopment/BuildFFUVM.ps1 | 24 ++++++++++++++----------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index 305f56c..5d52cbb 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -3708,7 +3708,8 @@ function New-PEMedia {
foreach ($Package in $Packages) {
$PackagePath = Join-Path $PackagePathBase $Package
WriteLog "Adding Package $Package"
- Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
+ # Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
+ Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WinPEFFUPath\mount /Add-Package /PackagePath:$PackagePath" | Out-Null
WriteLog "Adding package complete"
}
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
@@ -7404,10 +7405,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
@@ -7416,7 +7415,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
@@ -7429,23 +7429,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"
From 0607cf53868f1200e6b0f96976e3fe676dfe0473 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Tue, 14 Apr 2026 18:18:53 -0700
Subject: [PATCH 25/30] Updates package addition to use native cmdlet
Replaces the direct invocation of DISM via cmd with the native Windows PowerShell cmdlet to streamline and simplify the package installation process during WinPE media creation.
---
FFUDevelopment/BuildFFUVM.ps1 | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index 5d52cbb..fe09363 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -3708,8 +3708,7 @@ function New-PEMedia {
foreach ($Package in $Packages) {
$PackagePath = Join-Path $PackagePathBase $Package
WriteLog "Adding Package $Package"
- # Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
- Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WinPEFFUPath\mount /Add-Package /PackagePath:$PackagePath" | Out-Null
+ Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete"
}
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
From 24f10b89b0934cbd8e60141577cf3e648de0852c Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Wed, 15 Apr 2026 13:04:12 -0700
Subject: [PATCH 26/30] Fixes DeviceNamingMode UI state tracking and defaults
Updates the FFU UI core to better track the loaded `DeviceNamingMode` configuration.
Introduces flags to detect explicit user changes versus default states.
Ensures that if the user does not explicitly set the device naming mode, it defaults gracefully and preserves legacy script behaviors.
Updates documentation to reflect the actual behavior of not writing the `DeviceNamingMode` key unless modified.
---
FFUDevelopment/BuildFFUVM_UI.ps1 | 7 +-
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 19 +++--
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 77 +++++++++++++++++-
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 8 +-
FFUDevelopment/config/Sample_default.json | Bin 6826 -> 6760 bytes
docs/build.md | 7 +-
docs/parameters_reference.md | 2 +-
7 files changed, 102 insertions(+), 18 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index 6f2c462..f2503d6 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"
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index f9e24de..a7a5d6d 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -36,7 +36,7 @@ function Get-UIConfig {
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
- DeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
+ DeviceNamingMode = Get-ConfiguredDeviceNamingMode -State $State
DeviceNameTemplate = $State.Controls.txtDeviceNameTemplate.Text
DeviceNamePrefixesPath = $State.Controls.txtDeviceNamePrefixesPath.Text
DeviceNamePrefixes = @(Get-DeviceNamePrefixes -State $State)
@@ -480,17 +480,20 @@ function Update-UIFromConfig {
$State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
- $deviceNamingMode = 'None'
+ $loadedDeviceNamingMode = $null
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
- $deviceNamingMode = [string]$ConfigContent.DeviceNamingMode
+ $candidateDeviceNamingMode = [string]$ConfigContent.DeviceNamingMode
+ if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')) {
+ $loadedDeviceNamingMode = $candidateDeviceNamingMode
+ }
}
- if ($deviceNamingMode -eq 'Legacy') {
- $deviceNamingMode = 'Prompt'
+ $displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes')) {
+ $loadedDeviceNamingMode
}
- if ($deviceNamingMode -notin @('None', 'Prompt', 'Template', 'Prefixes')) {
- $deviceNamingMode = 'None'
+ else {
+ 'None'
}
- Set-DeviceNamingMode -State $State -Mode $deviceNamingMode
+ Set-DeviceNamingModeState -State $State -DisplayMode $displayDeviceNamingMode -LoadedMode $loadedDeviceNamingMode
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 37a0c96..6b86f6f 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -58,6 +58,55 @@ function Set-DeviceNamingMode {
$State.Controls.rbDeviceNamingPrefixes.IsChecked = $Mode -eq 'Prefixes'
}
+function Set-DeviceNamingModeState {
+ param(
+ [PSCustomObject]$State,
+ [ValidateSet('None', 'Prompt', 'Template', 'Prefixes')]
+ [string]$DisplayMode,
+ [AllowNull()]
+ [string]$LoadedMode
+ )
+
+ if ($null -eq $State.Flags) {
+ $State.Flags = @{}
+ }
+
+ if ($null -eq $State.Data) {
+ $State.Data = @{}
+ }
+
+ $previousSuppressionState = $true -eq $State.Flags.suppressDeviceNamingChangeTracking
+ $State.Flags.suppressDeviceNamingChangeTracking = $true
+ try {
+ Set-DeviceNamingMode -State $State -Mode $DisplayMode
+ }
+ finally {
+ $State.Flags.suppressDeviceNamingChangeTracking = $previousSuppressionState
+ }
+
+ $State.Data.loadedDeviceNamingMode = if ([string]::IsNullOrWhiteSpace($LoadedMode)) {
+ $null
+ }
+ else {
+ $LoadedMode.Trim()
+ }
+ $State.Flags.deviceNamingModeWasExplicitlyChanged = $false
+}
+
+function Get-ConfiguredDeviceNamingMode {
+ param([PSCustomObject]$State)
+
+ if (($null -ne $State.Flags) -and ($true -eq $State.Flags.deviceNamingModeWasExplicitlyChanged)) {
+ return Get-SelectedDeviceNamingMode -State $State
+ }
+
+ if (($null -ne $State.Data) -and -not [string]::IsNullOrWhiteSpace([string]$State.Data.loadedDeviceNamingMode)) {
+ return [string]$State.Data.loadedDeviceNamingMode
+ }
+
+ return $null
+}
+
function Get-DeviceNamePrefixes {
param([PSCustomObject]$State)
@@ -467,17 +516,32 @@ function Register-EventHandlers {
$State.Controls.rbDeviceNamingNone.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
- Update-DeviceNamingControls -State $window.Tag
+ $localState = $window.Tag
+ if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
+ $localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
+ $localState.Data.loadedDeviceNamingMode = $null
+ }
+ Update-DeviceNamingControls -State $localState
})
$State.Controls.rbDeviceNamingPrompt.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
- Update-DeviceNamingControls -State $window.Tag
+ $localState = $window.Tag
+ if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
+ $localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
+ $localState.Data.loadedDeviceNamingMode = $null
+ }
+ Update-DeviceNamingControls -State $localState
})
$State.Controls.rbDeviceNamingTemplate.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
- Update-DeviceNamingControls -State $window.Tag
+ $localState = $window.Tag
+ if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
+ $localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
+ $localState.Data.loadedDeviceNamingMode = $null
+ }
+ Update-DeviceNamingControls -State $localState
})
$State.Controls.txtDeviceNameTemplate.Add_TextChanged({
param($eventSource, $textChangedEventArgs)
@@ -489,7 +553,12 @@ function Register-EventHandlers {
$State.Controls.rbDeviceNamingPrefixes.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
- Update-DeviceNamingControls -State $window.Tag
+ $localState = $window.Tag
+ if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
+ $localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
+ $localState.Data.loadedDeviceNamingMode = $null
+ }
+ Update-DeviceNamingControls -State $localState
})
$State.Controls.btnBrowseDeviceNamePrefixesPath.Add_Click({
param($eventSource, $routedEventArgs)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 62dcdc0..43530b8 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -400,7 +400,13 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
- Set-DeviceNamingMode -State $State -Mode $State.Defaults.generalDefaults.DeviceNamingMode
+ $defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes')) {
+ $State.Defaults.generalDefaults.DeviceNamingMode
+ }
+ else {
+ 'None'
+ }
+ Set-DeviceNamingModeState -State $State -DisplayMode $defaultDeviceNamingMode -LoadedMode $null
$State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
$State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
$State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index dcc97ea59f3b218479d5eb387e6e203bfa03be38..09ee3809718125cf9310f12a19e6e85eae76fc66 100644
GIT binary patch
delta 12
TcmZ2w`od(x57y0AYy~0!Cjtc^
delta 44
zcmaE1vdVPB4_5I^hCGIJ249AJh7^WW1|*` for this default behavior.
+- If you leave device naming untouched, FFU Builder does not write `DeviceNamingMode` to the generated config. This preserves the script's `Legacy` default, so an existing `FFUDevelopment\Unattend\prefixes.txt` file is still copied to deployment media when present.
+- If you explicitly select this option, FFU Builder writes `DeviceNamingMode = None`. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
+
+The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `*` in the current sample files.
### Prompt for Device Name
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index ec31da0..400f89b 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -41,7 +41,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
-| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, and Prefixes. The UI uses None, Prompt, Template, and Prefixes. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. |
+| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, and Prefixes. The UI shows None, Prompt, Template, and Prefixes. When device naming is left untouched in the UI, the generated config does not write DeviceNamingMode, which preserves the script default of Legacy. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
| -UnattendX64FilePath | string | x64 Unattend File Path | Path to the x64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. |
From 38323e6be1a8ff0df8f7c54450ea2ad7d37fcef1 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:39:14 -0700
Subject: [PATCH 27/30] Add support for SerialComputerNames CSV mapping
Introduces a new `SerialComputerNames` device naming mode that allows automated device naming during deployment based on the BIOS serial number. The mapping is provided via a CSV file with `SerialNumber` and `ComputerName` columns.
This feature requires `CopyUnattend` and writes a `SerialComputerNames.csv` file to the USB deployment media, replacing the need for manual prompts or prefix selection when device serial numbers are known in advance. The UI has been updated to support creating, loading, and saving the CSV mapping content.
---
FFUDevelopment/BuildFFUVM.ps1 | 72 +++++++-
FFUDevelopment/BuildFFUVM_UI.ps1 | 16 ++
FFUDevelopment/BuildFFUVM_UI.xaml | 18 ++
.../FFUUI.Core/FFUUI.Core.Config.psm1 | 13 +-
.../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 154 +++++++++++++++++-
.../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 11 +-
FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 3 +
FFUDevelopment/config/Sample_default.json | Bin 6760 -> 7042 bytes
.../unattend/SampleSerialComputerNames.csv | 4 +
docs/build.md | 41 ++++-
docs/parameters_reference.md | 4 +-
docs/quickstart.md | 14 ++
12 files changed, 335 insertions(+), 15 deletions(-)
create mode 100644 FFUDevelopment/unattend/SampleSerialComputerNames.csv
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index fe09363..d37ff13 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -73,7 +73,7 @@ When set to $true, will copy the provisioning package from the $FFUDevelopmentPa
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, and Prefixes.
+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.
@@ -84,6 +84,12 @@ Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a l
.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.
@@ -425,11 +431,13 @@ param(
[bool]$AllowVHDXCaching,
[bool]$CopyPPKG,
[bool]$CopyUnattend,
- [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')]
+ [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,
@@ -644,7 +652,7 @@ function Save-StagedUnattendFile {
[Parameter(Mandatory = $true)]
[string]$DestinationPath,
[Parameter(Mandatory = $true)]
- [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')]
+ [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[string]$DeviceNamingMode,
[string]$DeviceNameTemplate,
[Parameter(Mandatory = $true)]
@@ -685,6 +693,11 @@ function Save-StagedUnattendFile {
$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' }
}
@@ -707,12 +720,24 @@ $resolvedDeviceNamePrefixesPath = if ([string]::IsNullOrWhiteSpace($DeviceNamePr
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.'
}
@@ -749,6 +774,38 @@ elseif ($DeviceNamingMode -eq 'Prefixes') {
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 = @(
@@ -4570,6 +4627,11 @@ Function New-DeploymentUSB {
$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' }
}
@@ -4644,6 +4706,10 @@ Function New-DeploymentUSB {
WriteLog "Writing prefixes.txt file to $UnattendPathOnUSB"
$using:effectiveDeviceNamePrefixes | Set-Content -Path (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Encoding UTF8
}
+ elseif ($using:DeviceNamingMode -eq 'SerialComputerNames') {
+ WriteLog "Writing SerialComputerNames.csv file to $UnattendPathOnUSB"
+ $using:effectiveDeviceNameSerialComputerNames | Set-Content -Path (Join-Path $UnattendPathOnUSB 'SerialComputerNames.csv') -Encoding UTF8
+ }
elseif ($legacyPrefixesWillBeStaged) {
WriteLog "Copying prefixes.txt file to $UnattendPathOnUSB"
Copy-Item -Path $using:resolvedDeviceNamePrefixesPath -Destination (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Force | Out-Null
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index f2503d6..2dd3c1e 100644
--- a/FFUDevelopment/BuildFFUVM_UI.ps1
+++ b/FFUDevelopment/BuildFFUVM_UI.ps1
@@ -528,6 +528,22 @@ $script:uiState.Controls.btnRun.Add_Click({
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
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index e1977cb..7de9f6d 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -963,6 +963,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
index a7a5d6d..90616a4 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1
@@ -40,6 +40,8 @@ function Get-UIConfig {
DeviceNameTemplate = $State.Controls.txtDeviceNameTemplate.Text
DeviceNamePrefixesPath = $State.Controls.txtDeviceNamePrefixesPath.Text
DeviceNamePrefixes = @(Get-DeviceNamePrefixes -State $State)
+ DeviceNameSerialComputerNamesPath = $State.Controls.txtDeviceNameSerialComputerNamesPath.Text
+ DeviceNameSerialComputerNames = @(Get-SerialComputerNamesLines -State $State)
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
@@ -473,21 +475,27 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixesPath' -State $State
+ Set-UIValue -ControlName 'txtDeviceNameSerialComputerNamesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNamesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameTemplate' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixes' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixes' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
+ Set-UIValue -ControlName 'txtDeviceNameSerialComputerNames' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNames' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixesPath.Text)) {
$State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
+ if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNamesPath.Text)) {
+ $State.Controls.txtDeviceNameSerialComputerNamesPath.Text = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
+ }
+
$loadedDeviceNamingMode = $null
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
$candidateDeviceNamingMode = [string]$ConfigContent.DeviceNamingMode
- if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes')) {
+ if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode = $candidateDeviceNamingMode
}
}
- $displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes')) {
+ $displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode
}
else {
@@ -495,6 +503,7 @@ function Update-UIFromConfig {
}
Set-DeviceNamingModeState -State $State -DisplayMode $displayDeviceNamingMode -LoadedMode $loadedDeviceNamingMode
Import-DeviceNamePrefixesFromConfiguredPath -State $State
+ Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
# Post Build Cleanup group (Build Tab)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index 6b86f6f..c77825d 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -42,13 +42,17 @@ function Get-SelectedDeviceNamingMode {
return 'Prefixes'
}
+ if ($true -eq $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked) {
+ return 'SerialComputerNames'
+ }
+
return 'None'
}
function Set-DeviceNamingMode {
param(
[PSCustomObject]$State,
- [ValidateSet('None', 'Prompt', 'Template', 'Prefixes')]
+ [ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[string]$Mode
)
@@ -56,12 +60,13 @@ function Set-DeviceNamingMode {
$State.Controls.rbDeviceNamingPrompt.IsChecked = $Mode -eq 'Prompt'
$State.Controls.rbDeviceNamingTemplate.IsChecked = $Mode -eq 'Template'
$State.Controls.rbDeviceNamingPrefixes.IsChecked = $Mode -eq 'Prefixes'
+ $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked = $Mode -eq 'SerialComputerNames'
}
function Set-DeviceNamingModeState {
param(
[PSCustomObject]$State,
- [ValidateSet('None', 'Prompt', 'Template', 'Prefixes')]
+ [ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[string]$DisplayMode,
[AllowNull()]
[string]$LoadedMode
@@ -121,6 +126,20 @@ function Get-DeviceNamePrefixes {
)
}
+function Get-SerialComputerNamesLines {
+ param([PSCustomObject]$State)
+
+ if ($null -eq $State.Controls.txtDeviceNameSerialComputerNames) {
+ return @()
+ }
+
+ return @(
+ $State.Controls.txtDeviceNameSerialComputerNames.Text -split "\r?\n" |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ ForEach-Object { $_.Trim() }
+ )
+}
+
function Import-DeviceNamePrefixesFile {
param(
[PSCustomObject]$State,
@@ -140,6 +159,25 @@ function Import-DeviceNamePrefixesFile {
return $true
}
+function Import-SerialComputerNamesFile {
+ param(
+ [PSCustomObject]$State,
+ [string]$FilePath
+ )
+
+ if ([string]::IsNullOrWhiteSpace($FilePath) -or -not (Test-Path -Path $FilePath -PathType Leaf)) {
+ return $false
+ }
+
+ $serialMappingLines = @(Get-Content -Path $FilePath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
+ if ($null -ne $State.Controls.txtDeviceNameSerialComputerNamesPath) {
+ $State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $FilePath
+ }
+ $State.Controls.txtDeviceNameSerialComputerNames.Text = $serialMappingLines -join [System.Environment]::NewLine
+ WriteLog "Imported serial computer-name mappings from $FilePath"
+ return $true
+}
+
function Get-DefaultDeviceNamePrefixesPath {
param([string]$FFUDevelopmentPath)
@@ -150,6 +188,16 @@ function Get-DefaultDeviceNamePrefixesPath {
return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'prefixes.txt'
}
+function Get-DefaultSerialComputerNamesPath {
+ param([string]$FFUDevelopmentPath)
+
+ if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
+ return $null
+ }
+
+ return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'SerialComputerNames.csv'
+}
+
function Get-DefaultUnattendFilePath {
param(
[string]$FFUDevelopmentPath,
@@ -188,6 +236,29 @@ function Import-DeviceNamePrefixesFromConfiguredPath {
}
}
+function Import-SerialComputerNamesFromConfiguredPath {
+ param(
+ [PSCustomObject]$State,
+ [switch]$SkipIfTextPresent
+ )
+
+ if ($SkipIfTextPresent -and -not [string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNames.Text)) {
+ return
+ }
+
+ $serialComputerNamesPath = $State.Controls.txtDeviceNameSerialComputerNamesPath.Text
+ if ([string]::IsNullOrWhiteSpace($serialComputerNamesPath)) {
+ $serialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
+ if (-not [string]::IsNullOrWhiteSpace($serialComputerNamesPath) -and $null -ne $State.Controls.txtDeviceNameSerialComputerNamesPath) {
+ $State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $serialComputerNamesPath
+ }
+ }
+
+ if (Test-Path -Path $serialComputerNamesPath -PathType Leaf) {
+ Import-SerialComputerNamesFile -State $State -FilePath $serialComputerNamesPath | Out-Null
+ }
+}
+
function Test-DeviceNameTemplateUsesSerialToken {
param([PSCustomObject]$State)
@@ -201,7 +272,7 @@ function Update-UnattendSelectionControls {
$isCopyUnattendSelected = $true -eq $State.Controls.chkCopyUnattend.IsChecked
$isInjectUnattendSelected = $true -eq $State.Controls.chkInjectUnattend.IsChecked
$deviceNameTemplateUsesSerialToken = Test-DeviceNameTemplateUsesSerialToken -State $State
- $requiresCopiedUnattend = ($selectedDeviceNamingMode -in @('Prompt', 'Prefixes')) -or $deviceNameTemplateUsesSerialToken
+ $requiresCopiedUnattend = ($selectedDeviceNamingMode -in @('Prompt', 'Prefixes', 'SerialComputerNames')) -or $deviceNameTemplateUsesSerialToken
if ($isCopyUnattendSelected -and $isInjectUnattendSelected) {
if ($requiresCopiedUnattend) {
@@ -247,19 +318,24 @@ function Update-UnattendSelectionControls {
function Update-DeviceNamingControls {
param([PSCustomObject]$State)
- if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and (($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked))) {
+ if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and (($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked))) {
$State.Controls.rbDeviceNamingNone.IsChecked = $true
}
$selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
$State.Controls.deviceNameTemplatePanel.Visibility = if ($selectedDeviceNamingMode -eq 'Template') { 'Visible' } else { 'Collapsed' }
$State.Controls.deviceNamePrefixesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'Prefixes') { 'Visible' } else { 'Collapsed' }
+ $State.Controls.deviceNameSerialComputerNamesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'SerialComputerNames') { 'Visible' } else { 'Collapsed' }
$State.Controls.rbDeviceNamingPrompt.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
$State.Controls.rbDeviceNamingPrefixes.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
+ $State.Controls.rbDeviceNamingSerialComputerNames.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
if ($selectedDeviceNamingMode -eq 'Prefixes') {
Import-DeviceNamePrefixesFromConfiguredPath -State $State -SkipIfTextPresent
}
+ elseif ($selectedDeviceNamingMode -eq 'SerialComputerNames') {
+ Import-SerialComputerNamesFromConfiguredPath -State $State -SkipIfTextPresent
+ }
Update-UnattendSelectionControls -State $State
}
@@ -480,18 +556,24 @@ function Register-EventHandlers {
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) {
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
+ $currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
$currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
$currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
$previousDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ $previousDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
$previousDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
$previousDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
$localState.Controls.txtFFUDevPath.Text = $selectedPath
$newDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $selectedPath
+ $newDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $selectedPath
$newDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'x64'
$newDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'arm64'
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath) -or $currentPrefixesPath -ieq $previousDefaultPrefixesPath) {
$localState.Controls.txtDeviceNamePrefixesPath.Text = $newDefaultPrefixesPath
}
+ if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath) -or $currentSerialComputerNamesPath -ieq $previousDefaultSerialComputerNamesPath) {
+ $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $newDefaultSerialComputerNamesPath
+ }
if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath) -or $currentUnattendX64FilePath -ieq $previousDefaultUnattendX64FilePath) {
$localState.Controls.txtUnattendX64FilePath.Text = $newDefaultUnattendX64FilePath
}
@@ -499,6 +581,7 @@ function Register-EventHandlers {
$localState.Controls.txtUnattendArm64FilePath.Text = $newDefaultUnattendArm64FilePath
}
Import-DeviceNamePrefixesFromConfiguredPath -State $localState
+ Import-SerialComputerNamesFromConfiguredPath -State $localState
Update-DeviceNamingControls -State $localState
}
})
@@ -560,6 +643,16 @@ function Register-EventHandlers {
}
Update-DeviceNamingControls -State $localState
})
+ $State.Controls.rbDeviceNamingSerialComputerNames.Add_Checked({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
+ $localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
+ $localState.Data.loadedDeviceNamingMode = $null
+ }
+ Update-DeviceNamingControls -State $localState
+ })
$State.Controls.btnBrowseDeviceNamePrefixesPath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
@@ -580,6 +673,26 @@ function Register-EventHandlers {
Update-DeviceNamingControls -State $localState
}
})
+ $State.Controls.btnBrowseDeviceNameSerialComputerNamesPath.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
+ if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
+ $currentSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ }
+ $initialDirectory = if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
+ $null
+ }
+ else {
+ Split-Path $currentSerialComputerNamesPath -Parent
+ }
+ $fileName = if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) { 'SerialComputerNames.csv' } else { Split-Path $currentSerialComputerNamesPath -Leaf }
+ $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select SerialComputerNames.csv file path' -Filter 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
+ if (Import-SerialComputerNamesFile -State $localState -FilePath $selectedPath) {
+ Update-DeviceNamingControls -State $localState
+ }
+ })
$State.Controls.btnBrowseUnattendX64FilePath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
@@ -653,6 +766,39 @@ function Register-EventHandlers {
[System.Windows.MessageBox]::Show("Saving prefixes failed for '$currentPrefixesPath'. $($_.Exception.Message)", "Save Prefixes Failed", "OK", "Error") | Out-Null
}
})
+ $State.Controls.btnSaveDeviceNameSerialComputerNames.Add_Click({
+ param($eventSource, $routedEventArgs)
+ $window = [System.Windows.Window]::GetWindow($eventSource)
+ $localState = $window.Tag
+ $serialComputerNameLines = @(Get-SerialComputerNamesLines -State $localState)
+
+ if ($serialComputerNameLines.Count -eq 0) {
+ [System.Windows.MessageBox]::Show("Enter CSV content before saving the serial mapping file.", "Serial Mapping Required", "OK", "Warning") | Out-Null
+ return
+ }
+
+ $currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
+ if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
+ $currentSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
+ if (-not [string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
+ $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $currentSerialComputerNamesPath
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
+ [System.Windows.MessageBox]::Show("Select a valid SerialComputerNames.csv file path before saving the serial mapping.", "Serial Mapping File Path Required", "OK", "Warning") | Out-Null
+ return
+ }
+
+ try {
+ $serialComputerNameLines | Set-Content -Path $currentSerialComputerNamesPath -Encoding UTF8
+ $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $currentSerialComputerNamesPath
+ WriteLog "Saved serial computer-name mappings to $currentSerialComputerNamesPath"
+ }
+ catch {
+ [System.Windows.MessageBox]::Show("Saving serial mapping failed for '$currentSerialComputerNamesPath'. $($_.Exception.Message)", "Save Serial Mapping Failed", "OK", "Error") | Out-Null
+ }
+ })
$State.Controls.chkCopyUnattend.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 43530b8..ca8f396 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -228,13 +228,19 @@ function Initialize-UIControls {
$State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
$State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
+ $State.Controls.rbDeviceNamingSerialComputerNames = $window.FindName('rbDeviceNamingSerialComputerNames')
$State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
$State.Controls.deviceNamePrefixesPanel = $window.FindName('deviceNamePrefixesPanel')
+ $State.Controls.deviceNameSerialComputerNamesPanel = $window.FindName('deviceNameSerialComputerNamesPanel')
$State.Controls.txtDeviceNameTemplate = $window.FindName('txtDeviceNameTemplate')
$State.Controls.txtDeviceNamePrefixesPath = $window.FindName('txtDeviceNamePrefixesPath')
$State.Controls.btnBrowseDeviceNamePrefixesPath = $window.FindName('btnBrowseDeviceNamePrefixesPath')
$State.Controls.txtDeviceNamePrefixes = $window.FindName('txtDeviceNamePrefixes')
$State.Controls.btnSaveDeviceNamePrefixes = $window.FindName('btnSaveDeviceNamePrefixes')
+ $State.Controls.txtDeviceNameSerialComputerNamesPath = $window.FindName('txtDeviceNameSerialComputerNamesPath')
+ $State.Controls.btnBrowseDeviceNameSerialComputerNamesPath = $window.FindName('btnBrowseDeviceNameSerialComputerNamesPath')
+ $State.Controls.txtDeviceNameSerialComputerNames = $window.FindName('txtDeviceNameSerialComputerNames')
+ $State.Controls.btnSaveDeviceNameSerialComputerNames = $window.FindName('btnSaveDeviceNameSerialComputerNames')
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
@@ -400,7 +406,7 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
- $defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes')) {
+ $defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$State.Defaults.generalDefaults.DeviceNamingMode
}
else {
@@ -410,7 +416,10 @@ function Initialize-UIDefaults {
$State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
$State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
$State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
+ $State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $State.Defaults.generalDefaults.DeviceNameSerialComputerNamesPath
+ $State.Controls.txtDeviceNameSerialComputerNames.Text = ($State.Defaults.generalDefaults.DeviceNameSerialComputerNames -join [System.Environment]::NewLine)
Import-DeviceNamePrefixesFromConfiguredPath -State $State
+ Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
index 8957f4a..5f41546 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
@@ -112,6 +112,7 @@ function Get-GeneralDefaults {
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
$deviceNamePrefixesPath = Join-Path -Path $unattendPath -ChildPath "prefixes.txt"
+ $deviceNameSerialComputerNamesPath = Join-Path -Path $unattendPath -ChildPath "SerialComputerNames.csv"
$unattendX64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_x64.xml"
$unattendArm64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_arm64.xml"
@@ -142,6 +143,8 @@ function Get-GeneralDefaults {
DeviceNameTemplate = ''
DeviceNamePrefixesPath = $deviceNamePrefixesPath
DeviceNamePrefixes = @()
+ DeviceNameSerialComputerNamesPath = $deviceNameSerialComputerNamesPath
+ DeviceNameSerialComputerNames = @()
CleanupAppsISO = $true
CleanupDeployISO = $true
CleanupDrivers = $false
diff --git a/FFUDevelopment/config/Sample_default.json b/FFUDevelopment/config/Sample_default.json
index 09ee3809718125cf9310f12a19e6e85eae76fc66..49192e40e14e1bea8cc293d813b00522e6187fa1 100644
GIT binary patch
delta 146
zcmaE1(qz8j58LE@Y$B?`45JE
delta 12
TcmZoNe_^uW58GxjjvQeCB*z5U
diff --git a/FFUDevelopment/unattend/SampleSerialComputerNames.csv b/FFUDevelopment/unattend/SampleSerialComputerNames.csv
new file mode 100644
index 0000000..58e390a
--- /dev/null
+++ b/FFUDevelopment/unattend/SampleSerialComputerNames.csv
@@ -0,0 +1,4 @@
+SerialNumber,ComputerName
+ABC12345,CORP-001
+DEF67890,KIOSK-010
+XYZ24680,STORE-015
\ No newline at end of file
diff --git a/docs/build.md b/docs/build.md
index 99f7b20..1e7bd04 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -340,7 +340,7 @@ This option is primarily intended for scenarios where:
| Limitation | Description |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **No prefixes.txt or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt` or the `%serial%` variable for deployment-time device naming |
+| **No prefixes.txt, SerialComputerNames.csv, or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt`, `SerialComputerNames.csv`, or the `%serial%` variable for deployment-time device naming |
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
@@ -348,7 +348,7 @@ This option is primarily intended for scenarios where:
> Note
>
-> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
+> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt`, `SerialComputerNames.csv`, and `%serial%` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
{: .tip-title}
@@ -423,6 +423,37 @@ Use **Prefixes File Path** to point the UI at the source text file for the prefi
Use **Save Prefixes** to write the current multiline prefixes list back to the file specified in **Prefixes File Path**.
+### Specify Serial to Device Name Mapping
+
+This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the `SerialNumber` column and uses the matching `ComputerName` value.
+
+Sample `SerialComputerNames.csv` content:
+
+```plaintext
+SerialNumber,ComputerName
+ABC12345,CORP-001
+DEF67890,KIOSK-010
+XYZ24680,STORE-015
+```
+
+- This option requires **Copy Unattend.xml**.
+- **Inject Unattend.xml** is not supported with this option.
+- If no matching serial number is found during deployment, `ApplyFFU.ps1` falls back to a random `FFU-*` computer name so setup can finish.
+
+{: .note-title}
+
+> Note
+>
+> If `prefixes.txt` and `SerialComputerNames.csv` are both staged manually on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder avoids this conflict by only staging the naming file for the selected device-naming mode.
+
+### SerialComputerNames.csv File Path
+
+Use **SerialComputerNames.csv File Path** to point the UI at the source CSV file for the serial-to-device-name mapping. The file can use any name. When you browse to a mapping file in the UI, or when a saved configuration references a valid CSV path, the UI loads that file and populates the multiline CSV box from its contents.
+
+### Save Serial Mapping
+
+Use **Save Serial Mapping** to write the current CSV content back to the file specified in **SerialComputerNames.csv File Path**.
+
### Deployment Prompt Compatibility
Older deployment media that already has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
@@ -443,8 +474,9 @@ The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
| **unattend_x64.xml** | Active unattend file used for x64 builds |
| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
| **SamplePrefixes.txt** | Example prefixes file for device naming |
+| **SampleSerialComputerNames.csv** | Example serial-to-device-name CSV file |
-Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, and `prefixes.txt` files.
+Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, `prefixes.txt`, and `SerialComputerNames.csv` files.
{: .note-title}
@@ -537,12 +569,13 @@ When enabled, the build process copies:
- The selected x64 or arm64 unattend XML file → renamed to **Unattend.xml** on the USB drive
- **prefixes.txt** → created from the **Device Naming** prefixes list when that mode is selected
+- **SerialComputerNames.csv** → created from the **Device Naming** serial mapping list when that mode is selected
If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
-See **Device Naming Expander** above for the available computer-name modes and prefixes-file behavior.
+See **Device Naming Expander** above for the available computer-name modes and naming-file behavior.
### Copy Provisioning Package
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index 400f89b..a87d625 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -41,12 +41,14 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
-| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, and Prefixes. The UI shows None, Prompt, Template, and Prefixes. When device naming is left untouched in the UI, the generated config does not write DeviceNamingMode, which preserves the script default of Legacy. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. |
+| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, Prefixes, and SerialComputerNames. The UI shows None, Prompt, Template, Prefixes, and SerialComputerNames. When device naming is left untouched in the UI, the generated config does not write DeviceNamingMode, which preserves the script default of Legacy. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. Prefixes writes prefixes.txt and requires -CopyUnattend. SerialComputerNames writes SerialComputerNames.csv and requires -CopyUnattend. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
+| -DeviceNameSerialComputerNamesPath | string | SerialComputerNames.csv File Path | Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and -DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. |
| -UnattendX64FilePath | string | x64 Unattend File Path | Path to the x64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. |
| -UnattendArm64FilePath | string | arm64 Unattend File Path | Path to the arm64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. |
| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
+| -DeviceNameSerialComputerNames | string[] | Specify Serial to Device Name Mapping | Sets the CSV content used when DeviceNamingMode is SerialComputerNames. The content must include SerialNumber and ComputerName headers, and the staged file is written as SerialComputerNames.csv on the deployment media. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. |
| -DriversFolder | string | Drivers Folder | Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. |
| -DriversJsonPath | string | Drivers.json Path | Path to a JSON file that specifies which drivers to download. |
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 8ed8fe7..01d8c1d 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -213,6 +213,20 @@ This option writes `prefixes.txt` from the list in the UI. Enter one prefix per
>
> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `*`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
+**Specify Serial to Device Name Mapping**
+
+This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the CSV and applies the matching computer name.
+
+- This option requires **Copy Unattend.xml**.
+- **Inject Unattend.xml** is not supported with this option.
+- If no matching serial number is found during deployment, `ApplyFFU.ps1` falls back to a random `FFU-*` computer name.
+
+{: .note-title}
+
+> Note
+>
+> If `prefixes.txt` and `SerialComputerNames.csv` are both present on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder stages only the naming file for the selected device-naming mode.
+
{: .warning-title}
> Warning
From 14811e3f9c7469b48e379fae47eeb9e3455e00cf Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:42:23 -0700
Subject: [PATCH 28/30] Updates version to 2604.1
Increments the script version identifiers to reflect the new release cycle.
---
FFUDevelopment/BuildFFUVM.ps1 | 2 +-
FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index d37ff13..a692f13 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -480,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) {
diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
index 9fd106a..49b93ba 100644
--- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
+++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
@@ -950,7 +950,7 @@ $LogFileName = 'ScriptLog.txt'
$USBDrive = Get-USBDrive
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
$LogFile = $USBDrive + $LogFilename
-$version = '2603.2'
+$version = '2604.1'
WriteLog 'Begin Logging'
WriteLog "Script version: $version"
From 6b76f6b9a27949be972a4825f88307873bc562f1 Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 16 Apr 2026 14:25:47 -0700
Subject: [PATCH 29/30] Renames serial mapping file path UI labels
Updates UI labels, tooltips, message boxes, dialog titles, and corresponding documentation to use "Serial Computer Names CSV Mapping File Path" instead of "SerialComputerNames.csv File Path" to improve clarity and presentation.
Co-authored-by: Copilot
---
FFUDevelopment/BuildFFUVM_UI.ps1 | 2 +-
FFUDevelopment/BuildFFUVM_UI.xaml | 4 ++--
FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 | 4 ++--
docs/build.md | 6 +++---
docs/parameters_reference.md | 2 +-
5 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1
index 2dd3c1e..ac5de98 100644
--- a/FFUDevelopment/BuildFFUVM_UI.ps1
+++ b/FFUDevelopment/BuildFFUVM_UI.ps1
@@ -538,7 +538,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$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
+ [System.Windows.MessageBox]::Show("Enter CSV content or choose a valid Serial Computer Names CSV Mapping File Path 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
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 7de9f6d..c668923 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -965,14 +965,14 @@
-
+
-
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
index c77825d..6049179 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1
@@ -688,7 +688,7 @@ function Register-EventHandlers {
Split-Path $currentSerialComputerNamesPath -Parent
}
$fileName = if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) { 'SerialComputerNames.csv' } else { Split-Path $currentSerialComputerNamesPath -Leaf }
- $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select SerialComputerNames.csv file path' -Filter 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
+ $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select Serial Computer Names CSV Mapping File Path' -Filter 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
if (Import-SerialComputerNamesFile -State $localState -FilePath $selectedPath) {
Update-DeviceNamingControls -State $localState
}
@@ -786,7 +786,7 @@ function Register-EventHandlers {
}
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
- [System.Windows.MessageBox]::Show("Select a valid SerialComputerNames.csv file path before saving the serial mapping.", "Serial Mapping File Path Required", "OK", "Warning") | Out-Null
+ [System.Windows.MessageBox]::Show("Select a valid Serial Computer Names CSV Mapping File Path before saving the serial mapping.", "Serial Mapping File Path Required", "OK", "Warning") | Out-Null
return
}
diff --git a/docs/build.md b/docs/build.md
index 1e7bd04..e968463 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -446,13 +446,13 @@ XYZ24680,STORE-015
>
> If `prefixes.txt` and `SerialComputerNames.csv` are both staged manually on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder avoids this conflict by only staging the naming file for the selected device-naming mode.
-### SerialComputerNames.csv File Path
+### Serial Computer Names CSV Mapping File Path
-Use **SerialComputerNames.csv File Path** to point the UI at the source CSV file for the serial-to-device-name mapping. The file can use any name. When you browse to a mapping file in the UI, or when a saved configuration references a valid CSV path, the UI loads that file and populates the multiline CSV box from its contents.
+Use **Serial Computer Names CSV Mapping File Path** to point the UI at the source CSV file for the serial-to-device-name mapping. The file can use any name. When you browse to a mapping file in the UI, or when a saved configuration references a valid CSV path, the UI loads that file and populates the multiline CSV box from its contents.
### Save Serial Mapping
-Use **Save Serial Mapping** to write the current CSV content back to the file specified in **SerialComputerNames.csv File Path**.
+Use **Save Serial Mapping** to write the current CSV content back to the file specified in **Serial Computer Names CSV Mapping File Path**.
### Deployment Prompt Compatibility
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index a87d625..b5a2e8a 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -44,7 +44,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, Prefixes, and SerialComputerNames. The UI shows None, Prompt, Template, Prefixes, and SerialComputerNames. When device naming is left untouched in the UI, the generated config does not write DeviceNamingMode, which preserves the script default of Legacy. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. Prefixes writes prefixes.txt and requires -CopyUnattend. SerialComputerNames writes SerialComputerNames.csv and requires -CopyUnattend. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
-| -DeviceNameSerialComputerNamesPath | string | SerialComputerNames.csv File Path | Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and -DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. |
+| -DeviceNameSerialComputerNamesPath | string | Serial Computer Names CSV Mapping File Path | Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and -DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. |
| -UnattendX64FilePath | string | x64 Unattend File Path | Path to the x64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. |
| -UnattendArm64FilePath | string | arm64 Unattend File Path | Path to the arm64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. |
| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
From 15ca246abd71d0c554a133e9f9f4d328c88c251d Mon Sep 17 00:00:00 2001
From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com>
Date: Thu, 16 Apr 2026 15:46:02 -0700
Subject: [PATCH 30/30] Updates documentation screenshots and UI descriptions
Refreshes numerous screenshot image references across the documentation to reflect the latest UI changes. Updates text descriptions in several documents (Build, Prerequisites, Quickstart, Windows Settings) to align with recent features, such as the new Windows Media Source options, explicitly mentioning PowerShell 7.6+ requirements, and clarifying the Device Naming and Copy Unattend.xml behavior.
Co-authored-by: Copilot
---
docs/M365appsoffice.md | 4 +-
docs/applications.md | 2 +-
docs/appsscriptvariables.md | 6 +-
docs/build.md | 78 ++++++++++--------
docs/byoapps.md | 2 +-
docs/drivers.md | 4 +-
docs/hyperv_settings.md | 2 +-
docs/image/M365appsoffice/1776379074990.png | Bin 0 -> 170168 bytes
docs/image/Prerequisites/1776376971272.png | Bin 0 -> 287411 bytes
docs/image/applications/1776378755854.png | Bin 0 -> 153881 bytes
.../appsscriptvariables/1776379026213.png | Bin 0 -> 51776 bytes
docs/image/build/1776379303045.png | Bin 0 -> 204901 bytes
docs/image/byoapps/1776378975827.png | Bin 0 -> 150504 bytes
docs/image/drivers/1776379108391.png | Bin 0 -> 252740 bytes
docs/image/drivers/1776379241359.png | Bin 0 -> 255095 bytes
docs/image/hyperv_settings/1776378277110.png | Bin 0 -> 166032 bytes
docs/image/monitor/1776379380344.png | Bin 0 -> 383314 bytes
docs/image/quickstart/1776377145296.png | Bin 0 -> 269149 bytes
docs/image/quickstart/1776377420583.png | Bin 0 -> 202032 bytes
docs/image/quickstart/1776377442979.png | Bin 0 -> 256124 bytes
docs/image/quickstart/1776377516879.png | Bin 0 -> 208056 bytes
docs/image/quickstart/1776377592432.png | Bin 0 -> 222929 bytes
docs/image/quickstart/1776377622826.png | Bin 0 -> 216385 bytes
docs/image/ui_overview/1776378210391.png | Bin 0 -> 300406 bytes
docs/image/updates/1776378727768.png | Bin 0 -> 175763 bytes
docs/image/windows_settings/1776378316739.png | Bin 0 -> 262546 bytes
docs/image/winget/1776378791094.png | Bin 0 -> 338348 bytes
docs/image/winget/1776378813453.png | Bin 0 -> 23694 bytes
docs/image/winget/1776378860799.png | Bin 0 -> 131712 bytes
docs/image/winget/1776378878837.png | Bin 0 -> 17101 bytes
docs/monitor.md | 4 +-
docs/parameters_reference.md | 13 +--
docs/prerequisites.md | 6 +-
docs/quickstart.md | 28 +++++--
docs/ui_overview.md | 4 +-
docs/updates.md | 4 +-
docs/windows_settings.md | 22 +++--
docs/winget.md | 8 +-
38 files changed, 108 insertions(+), 79 deletions(-)
create mode 100644 docs/image/M365appsoffice/1776379074990.png
create mode 100644 docs/image/Prerequisites/1776376971272.png
create mode 100644 docs/image/applications/1776378755854.png
create mode 100644 docs/image/appsscriptvariables/1776379026213.png
create mode 100644 docs/image/build/1776379303045.png
create mode 100644 docs/image/byoapps/1776378975827.png
create mode 100644 docs/image/drivers/1776379108391.png
create mode 100644 docs/image/drivers/1776379241359.png
create mode 100644 docs/image/hyperv_settings/1776378277110.png
create mode 100644 docs/image/monitor/1776379380344.png
create mode 100644 docs/image/quickstart/1776377145296.png
create mode 100644 docs/image/quickstart/1776377420583.png
create mode 100644 docs/image/quickstart/1776377442979.png
create mode 100644 docs/image/quickstart/1776377516879.png
create mode 100644 docs/image/quickstart/1776377592432.png
create mode 100644 docs/image/quickstart/1776377622826.png
create mode 100644 docs/image/ui_overview/1776378210391.png
create mode 100644 docs/image/updates/1776378727768.png
create mode 100644 docs/image/windows_settings/1776378316739.png
create mode 100644 docs/image/winget/1776378791094.png
create mode 100644 docs/image/winget/1776378813453.png
create mode 100644 docs/image/winget/1776378860799.png
create mode 100644 docs/image/winget/1776378878837.png
diff --git a/docs/M365appsoffice.md b/docs/M365appsoffice.md
index a73027e..99da68a 100644
--- a/docs/M365appsoffice.md
+++ b/docs/M365appsoffice.md
@@ -9,7 +9,7 @@ parent: UI Overview
---
# M365 Apps/Office
-
+
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
@@ -64,6 +64,6 @@ For more information about deploying languages see: [Overview of deploying langu
## Copy Office Configuration XML
-If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
+If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
{% include page_nav.html %}
diff --git a/docs/applications.md b/docs/applications.md
index c1957ab..5ed9c84 100644
--- a/docs/applications.md
+++ b/docs/applications.md
@@ -10,7 +10,7 @@ has_toc: false
---
# Applications
-
+
Applications can be installed in three different ways:
diff --git a/docs/appsscriptvariables.md b/docs/appsscriptvariables.md
index a7a6845..79be4c1 100644
--- a/docs/appsscriptvariables.md
+++ b/docs/appsscriptvariables.md
@@ -10,7 +10,7 @@ grand_parent: UI Overview
---
# Apps Script Variables
-
+
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
@@ -45,7 +45,7 @@ In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `App
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
```
-{
+s{
"AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false,
@@ -128,6 +128,4 @@ Example command line to run with vmwaretools set to false and foo set to foo. Th
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
-
-
{% include page_nav.html %}
diff --git a/docs/build.md b/docs/build.md
index e968463..2784f30 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -9,7 +9,7 @@ parent: UI Overview
---
# Build
-
+
The Build tab is where the magic happens
@@ -283,6 +283,12 @@ The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_D
>
> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
+### Verbose
+
+Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
+
+In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
+
## Unattend.xml Options Expander
Use the **Unattend.xml Options** expander to choose how unattend content is staged and which source XML file FFU Builder should use for x64 and arm64 builds.
@@ -338,11 +344,11 @@ This option is primarily intended for scenarios where:
#### Limitations
-| Limitation | Description |
-| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **No prefixes.txt, SerialComputerNames.csv, or %serial% support** | Unlike **Copy Unattend.xml**, this method does not support `prefixes.txt`, `SerialComputerNames.csv`, or the `%serial%` variable for deployment-time device naming |
-| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
-| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
+| Limitation | Description |
+| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **No prefixes.txt, SerialComputerNames.csv, or %serial% support** | Unlike**Copy Unattend.xml**, this method does not support `prefixes.txt`, `SerialComputerNames.csv`, or the `%serial%` variable for deployment-time device naming |
+| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
+| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
{: .note-title}
@@ -356,11 +362,23 @@ This option is primarily intended for scenarios where:
>
> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
-### Verbose
+### Copy Unattend.xml
-Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
+Controls the `-CopyUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
-In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
+Use this option when you plan to build deployment USB media.
+
+When enabled, the build process copies:
+
+- The selected x64 or arm64 unattend XML file -> renamed to **Unattend.xml** on the USB drive
+- **prefixes.txt** -> created from the **Device Naming** prefixes list when that mode is selected
+- **SerialComputerNames.csv** -> created from the **Device Naming** serial mapping list when that mode is selected
+
+If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
+
+During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
+
+See **Device Naming Expander** below for the available computer-name modes and naming-file behavior.
## Device Naming Expander
@@ -374,7 +392,7 @@ Use the **Device Naming** expander to decide whether `ComputerName` should be se
This is the default radio selection in the UI.
- If you leave device naming untouched, FFU Builder does not write `DeviceNamingMode` to the generated config. This preserves the script's `Legacy` default, so an existing `FFUDevelopment\Unattend\prefixes.txt` file is still copied to deployment media when present.
-- If you explicitly select this option, FFU Builder writes `DeviceNamingMode = None`. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
+- If you explicitly select this option, FFU Builder sets `DeviceNamingMode = None`. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `*` in the current sample files.
@@ -382,6 +400,7 @@ The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `*`.
- During the build, FFU Builder rewrites only the staged deployment copy of `Unattend.xml` to the legacy prompt placeholder that `ApplyFFU.ps1` already recognizes.
@@ -391,6 +410,7 @@ Use this option when you want the technician to enter the computer name during d
Use this option when you want a static device name or a template such as `Comp-%serial%`.
+- FFU Builder sets `DeviceNamingMode = Template`.
- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
- With **Inject Unattend.xml**, only static names are supported.
- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
@@ -399,6 +419,8 @@ Use this option when you want a static device name or a template such as `Comp-%
This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line in the multiline prefixes box. If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
+- FFU Builder sets `DeviceNamingMode = Prefixes`.
+
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
Sample `prefixes.txt` content:
@@ -427,6 +449,8 @@ Use **Save Prefixes** to write the current multiline prefixes list back to the f
This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the `SerialNumber` column and uses the matching `ComputerName` value.
+- FFU Builder sets `DeviceNamingMode = SerialComputerNames`.
+
Sample `SerialComputerNames.csv` content:
```plaintext
@@ -468,13 +492,13 @@ Older deployment media that already has an unattend file with `ComputerName` set
The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
-| File | Description |
-| -------------------------------- | ------------------------------------------ |
-| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
-| **unattend_x64.xml** | Active unattend file used for x64 builds |
-| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
-| **SamplePrefixes.txt** | Example prefixes file for device naming |
-| **SampleSerialComputerNames.csv** | Example serial-to-device-name CSV file |
+| File | Description |
+| --------------------------------------- | ------------------------------------------ |
+| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
+| **unattend_x64.xml** | Active unattend file used for x64 builds |
+| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
+| **SamplePrefixes.txt** | Example prefixes file for device naming |
+| **SampleSerialComputerNames.csv** | Example serial-to-device-name CSV file |
Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, `prefixes.txt`, and `SerialComputerNames.csv` files.
@@ -559,24 +583,6 @@ This option is only available when **Build USB Drive** is checked.
This leverages the Autopilot for existing devices json file. It's not recommended to use this method any longer as devices enrolled via this method are enrolled as personal instead of corporate.
-### Copy Unattend.xml
-
-Controls the `-CopyUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
-
-Use this option when you plan to build deployment USB media.
-
-When enabled, the build process copies:
-
-- The selected x64 or arm64 unattend XML file → renamed to **Unattend.xml** on the USB drive
-- **prefixes.txt** → created from the **Device Naming** prefixes list when that mode is selected
-- **SerialComputerNames.csv** → created from the **Device Naming** serial mapping list when that mode is selected
-
-If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
-
-During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
-
-See **Device Naming Expander** above for the available computer-name modes and naming-file behavior.
-
### Copy Provisioning Package
Controls the `-CopyPPKG` parameter. When checked, copies the contents of `.\FFUDevelopment\PPKG` to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
@@ -745,7 +751,7 @@ During the build process, application content accumulates in several subfolders
| Folder | Contents |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
-| `Win32` | Winget source applications and Bring Your Own Apps content copied using the **Copy Apps** button or manually copied |
+| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
| `MSStore` | Microsoft Store applications downloaded via Winget |
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
diff --git a/docs/byoapps.md b/docs/byoapps.md
index 007c049..0f8d179 100644
--- a/docs/byoapps.md
+++ b/docs/byoapps.md
@@ -10,7 +10,7 @@ grand_parent: UI Overview
---
# Bring Your Own Applications
-
+
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
diff --git a/docs/drivers.md b/docs/drivers.md
index 34b6db2..2396b79 100644
--- a/docs/drivers.md
+++ b/docs/drivers.md
@@ -9,7 +9,7 @@ parent: UI Overview
---
# Drivers
-
+
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
@@ -72,7 +72,7 @@ To find the Machine Type for Lenovo devices, check the bottom/back of the device
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
-
+
## Save Drivers.json
diff --git a/docs/hyperv_settings.md b/docs/hyperv_settings.md
index 9d3e04f..bdc8b90 100644
--- a/docs/hyperv_settings.md
+++ b/docs/hyperv_settings.md
@@ -9,7 +9,7 @@ parent: UI Overview
---
# Hyper-V Settings
-
+
## Enable VM Networking (Experimental)
diff --git a/docs/image/M365appsoffice/1776379074990.png b/docs/image/M365appsoffice/1776379074990.png
new file mode 100644
index 0000000000000000000000000000000000000000..85838dc01691dd4bc03ab260e13b284fd0d16d02
GIT binary patch
literal 170168
zcmd43cRba7_&%yqL3|xA~Sm&4SR-Uk3vMqF6+qNdvtJ&B-_c#reyEE
z$L~72@B35t_w)VZ_uu#ZDBjL{yenzI3m0C(iHshm*FkhTL?s6AmQ$#|OPtLJ4s-VXG9Clk?{E^Kd6xbQBqHi&>ah
z;Ws>W{TTB5oLX5Q@$O#;gu8Eyc722f2U+A=psgr@lZzK7@>6VOE`gJ5-nW^rUp?p9+q>Mml
zeL}Gz5Ufjsv;di-vs0v?XBt%q6JKOoZTsj3$5wmKf+Z)JSqr(O`?xoPE4OF;jrM+um
zc*Gmk!cfq#iglBfEa=tazR+T4$&k=y#vOTIvY=rR>yl8d$K7fAo#vWrUrGqxy+BY?
zv+%XbPL^%?^h~$R9mSp**4Zwy^<6KOo*CLbB36w9qT#v%lf8xo`A3{-Ox+K9>W()0
z#ycb9Hp9HTJ#r#=lZDFD5Qt(=Q4It_b)@Vz*&9225T|blMw>KVP6y>j!I=?{HQ?Vi
z<85f0a68Bd5TR_5e4=89?ca`Ce)1p5BgAOOf?U_vEMudi;|DfOzhi8B4MkhtYomh;
zSP)sBv0<`tvJCpHlWs@9GRU0oGvj`D|BGhPOw8-RaGGQPy%_;>;Hu5-N`nSNB`?xX
zX0DklHASYXro)YH4SXHiIvGR$BCO~Mw+&a`sCkRy=F0m!*e_UvkPogk$Ry_8Lizhh(lVD7JoKaM1g@Iaq8Z;(N!Tooh%*NRn!hf82gyD%^2C+VL{0463`?VeNk2$LD*cW4o=b
zr40M7gq3^CNo@(E{J7_iy?H-*{kH)W%gBlHt(wLZCqH
zO%3j^1Twt$+7ta?7y{~!Q4J_IqK{VbNoBO)!oZciqii6(mUNi2f`vwKZdo*SH*KI1e7tK&oBt`f6U9=s2@+Z@(u_tK@M{-)@i|F%REMY&(XTf8ST8L*{{kTv6uU`5mA_tF_TYouGr~gS5ijz
zD6*j`$n|-SYE45g{nUNmYwy|G4E60MY%3Skmzx;$XM07c##BZ5$JKh
z^MBsBOoU`c&4|bZQ7eD0cQZDSC$tQZMk@8pAQFQP5UOrMv|IpRQzk8)9%gwzSd>aMtPqs;gvKy_G(chtYztel)&~R{!
zRZa9@nUePNb@_?YQ*82?C&l{3S2g5Box0Y9Z5XytlPjW5q6Y;_5M!1tXLss22{ESK
z%CBnV6Q_kPMnddqi2Ez9g(0Cq@7e`ogM?}`?#&yno9$b7*0!47!eAc^RXlQfj#qwd
zmX_wi!nmoP_zm
zgIV!d_H!}twq~ei$tSxo5m)lhch%QO2|jbn1YLDktGlNIF7b@iWbgCEC{u)3-Ux{U
z0@28`pLznf1cK%g1Y1i;VLcY>ZxFK7l2>z}UQ~6)1-V}rR8p}iJkyv~hBoxT&DFm#w?Xg5
z1!Cge{=}=22RjO?^)WGS#?Jy=KDoF|PsA#ckVTkw=P)wFC|YP}IO?Ld%Zm4d1d-a>
z+rmRq_+0yQ8=XgHNRr($&5mY$TUZj&(AYM7bxKVh&t)}_bmX?sJW#CY
zue>moMX{wFyTeHx{t#;uTImcC_uJh^T&l0`Ca8t~Zpgtd?HrO8(4&aQ47O
z9yj~xH}A!L+$kjQ8qYpnS`FC)SN!^(;ipe@iQjbl?b0JS{i(-hGi3y9BnfV?o^b3+
zPvRh;Ew&h@Rl#{o1BMH}%T4!JFW}}zeYN7mF$7a6P4m(OPJtWA^}8p`1;s~S`SzNf
zA{5oTkZojrBF>y;JDP59R;7EQy;kOzMYaGIGUAPLv|GqE{5cX}uElcw2cyyndE{F6
zyiZ||s0Gr8`yj-Lm&xwFWI{tC8;DQSaK>`zQvfL`0#i1j;56O$!e!L2w;+@%j`D_iM6mttaGk^DbuVgC@j{~^
zWea>-Py6i#*T(ra>jZq0u1sc?@|n(X7S;3_9Vw2JKYsk+i1567Uns=s*lfI+N7m|M^b?j4aA2FszAF$X$eskv2VipYlY3aMM1N_v^0xr3)!^-_LcoYPH4dE
zkouy%FPGI|aa5%)fLhKEiE{ZFZ8sO$616>c(_1>_z^T~4FU7NWAlG=OX(D9w`N{rL
z7b(^7p$t`a`bjnin}G~*pL#l}u~)W8r!9Kg&qb!)>|vQ~n1X`r+dI$Ebp-k|>r~?S
zsO>cy#*`DQZb}$shN#V*u~;_W2TLc#ed?U{Py?hm56!c*(bU*}@bTIEfnTAA{1K68J$@E9+XiyZhBI%_-!(k{Mr}U(kDh|I6s;XzJwots8c=^g7>EvL2LGRFYny
z=hC!@$Sp{lI7KDG=DBITo*5w-nBR$+>D;&N9RYaPovnMY+mvH-=?M;YZtcFkCGIoK
zyJx}75W`RBx%sg1s>~S7h53n>HSe1C=#K9xE6lzcJW}$
z=_}^r-s=p{l;JYK!5)@_xo;Vjx|JkaM0>m&=Ig=2+cRmzQN?bHZHBd<5B{QOh_QP9
zt>xWCGQisa@;BysE*2XacDKfWTYK@q5HJrTO-RK0Tpv^2e19I#{$^0g@cD_^Os(A&
zg0*w?!yM{YJ&ig_yOwUB2y5^fc17E{83mlxm{=CF!wd(;ad
zg3pDPL~l)SY8#C#7``7Uc`}oo<+eJt_U?u;CU?|)Af!Q$+hQ=!Zhcn6%FXh=e2>+{xJpRFL*5H53?66Pd86J;>?55W
z`!hX74Ws*ncq|?~Y_<$Z=*BoXw`kRMm)q{&eVULw_duQ|f@b=~%_zrg@c0#*6Xeo(
zecdS)Ir4ie6db!nE)xlX8~q=*NVeRaKdNP=Uz(YDpo@R{EG3`qmu6g)i|zfm71%xO
z%*Vhhcj4Pq^tprbE8n}UrZ2F@$O>;XfBkU?^POF$yVEtUb1gOdxJLw1`!x#p0w!=X
zA9fJlE46kgGWm92z{UVV&9vV0M=sa)ex5$UDMc;fIJa$^qge!3&qP0Kl-le3J~3(x
z%M&VEo3FFsGMcrJHmQc=VE*33g0hA`-s1?9LMLFQak}-qh;s8Jt$}li#ny;~;h7Ox
z9CE!uN!&;kVm&c@aPdCC=+p|gI=vh3-ryB9q(--)gJ;T6S%U?(4&u$j%LzvyFRR+#
zeB5^X^*7OPdrY%yg8$Y
zu8|lfDWTIJJ1A6rAzxoPN}s`op?3CXqibxBB0xNJ+O+u#aaqxgQ+<)Wv0^(G5{!3*_q9_yu+}-z`P{u`W9qNB8F4hw
z!^1HUY#+FP%lq#b9(+HbDDnM!474zYK$UV9GZ@2(eB^1)hWOlWRqa5+p8QX)$T+>Q
zBw#Fp^zUdmJ~)CfBXJ8rAg(5&6t(TWK?Nv-NnP%wboe`rt>rG>ww6!2y;Cexp_|%0
zL&@u;J|8CK)p&!#BJsHfiTZB!GK$@5d-W8=t
zxPlCpc$Aa%k(we`-)pa2tbs))=Y%d@lTZF?@=-+3`F+wOh*1U?PW399bN?ejy?Mjy
zC>rj&+2cPWcxD*(8SiF?OPQ*1m~T8~o#|)}N34EGsKX<6x@8e@%?X!Ax^pK^{*$b=
z^^(6cIEJYDbpI7O92%=S(IL98-y(oPastYoS*y&{*r;9&9Fyc%sp$|NsaIr8z>534
zdGki`iLU6gRdri=JG9zz&kn$?`pg4;wKlge#(Pf&13w{kALS(HPH<+v%t<+KgFP<#
zHq3Xyn!rl9b~8>QXy>KO#wTlCtT6e~%Y~n(7t(wp^b~tc|F5!tAu5PK9Isz-sunt!
zt+zRo`<)8#ocqY@x`U+fV^|cW^nhyX-G|
zuM)-zelhSnNAZ#3@N}_S$??O?{O>!3QYE5r
zl!|ZT{Z)R(SYkvyjV|301XIfF=wadn3b}Gu6c?4q&oJ}yy!;N{w?gtLLP4(gvbs5huZt=@7!C@tzn)~HS33imOk=pkt7sChig}&4sl3*)Zn0H37eg-
zVjhb0*H!j$hjS0*F*uWudtvv>CEUlpA;GpB=!GKb)zM#PRUH$@jXQ4kT9gdkcJvBg
z&Ujggk{)5VLVIOkv1FR=ZDx@hxKl%KA9s#W5o>a+wf<)kt3fymW1^F$-C)aO9jnUw
zbS}I4oU5+(bhpAPVXjxi;falJ;@v2Jtu`X`?BOyZgw&HNZ23U*&sE<;Sn
znA!2e$&rbzX?3*^J6{|-x