Compare commits

..

3 Commits

Author SHA1 Message Date
rbalsleyMSFT 65e52bb554 Updates boot file generation to use ADK BCDBoot
Addresses potential inconsistencies with Secure Boot servicing states by using the validated ADK toolset's BCDBoot instead of relying on the local OS installation. Passed ADK path and architecture parameters are now utilized to ensure boot binaries remain consistent across environments.
2026-03-13 16:32:02 -07:00
rbalsleyMSFT c20829a72c Removes WinPE-SecureBootCmdlets_en_us.cab reference as that doesn't exist. 2026-03-13 09:21:27 -07:00
rbalsleyMSFT 7670ab886c Adds Secure Boot deployment diagnostics
Includes Secure Boot support in the PE image so firmware variables can be inspected during imaging.

Captures baseline, post-apply, and final boot evidence for firmware state, storage layout, boot files, and boot configuration to explain UEFI boot failures and highlight likely dbx blocks or boot entry issues.
2026-03-12 15:59:04 -07:00
71 changed files with 4151 additions and 5631 deletions
-86
View File
@@ -1,91 +1,5 @@
# Change Log # Change Log
# 2603.2
## What's Changed
Highly recommended to upgrade to this release due to potential SecureBoot issues
### Fixes SecureBoot-related boot issues on newly deployed FFUs
Fixes an issue where some devices may not boot after an FFU was applied if the FFU was built from a machine that had been updated to the Windows UEFI CA 2023 SecureBoot certificates.
Sometime at the start of this calendar year (Either January or February), a change was made in Windows to how BCDBoot functioned. If you took a CU from either of these months, BCDBoot will now check to see if the device has the 2011 or 2023 CA certificates. If 2023, the local BCDBoot will use the 2023 signed boot files when creating the System partition and these boot files will be deployed to the target system when the FFU is deployed. If the target machine hasn't updated to the 2023 certificates, boot will fail.
To fix this, FFU Builder now uses the version of BCDBoot from the ADK instead of the locally installed version. The version of BCDBoot from the 10.1.26100.2454 ADK December 2024 version (which is what FFU Builder considers the latest), will provide the boot files signed with the 2011 certs.
The version of [BCDBoot from the 10.1.28000.1 ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/what-s-new-in-kits-and-tools#bcd-boot) from November 2025 will default to using the 2023 certs as long as the machine supports the 2023 CA. This has been documented since this ADK was released. The behavior of this version of BCDBoot is what we're seeing now in devices that have been recently updated.
I suspect that when 26H2 is released, there will be a new ADK around that time and at that point we'll move to using that version of the ADK, which that version of BCDBoot will default to using the 2023 signed boot files and I suspect WinPE will probably default to doing the same. By then, hopefully, most in-market devices should have the 2023 certificates in UEFI and those that don't will need to get the certs or downgrade their ADK version to use the 2011 signed boot files.
### Fixes working directory handling
Creation and deletion of the dirty.txt marker file now use an explicit path based on $FFUDevelopmentPath, avoiding ambiguity and potential issues with relative paths.
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2603.1...v2603.2
# 2603.1
## What's Changed
### UI Out of Preview
The UI is finally out of preview and the code from the UI branch has been pushed to main.
### Remove old MSU files before servicing
Fixes an issue where older cumulative update MSU files were mixed with newer files. In Windows 11 24H2, dism treats all MSU files in the same folder as possible update sources, causing servicing issues.
This will primarily be an issue for those of you who use ISO files that don't have the latest updates, or those of you who create your FFUs with the ESD media shortly after patch Tuesday.
### Adds OS-scoped update KB folders
The KB folder where the CU and .NET updates downloads to will now have sub-folders for each Windows version. You may notice that if selecting 25H2, that the KB sub-folder for Windows 11 will show 24H2, this is to keep consistent with Windows 11 LTSC 2024 and Windows Server 2025, which all use the same 24H2 source OS (25H2 is just an eKB, not a full OS Swap Windows release like 24H2 was).
### .NET Updates stored under their own dedicated KB folder
This is more so to keep things clean, rather than fixing any sort of technical issue
### Reworked Windows 10 LTSB\LTSC Cumulative Update Installation
Since Windows 10 is out of support, offline servicing Windows 10 LTSC builds that are still supported fails due to extended security update requirements. The workaround to this is to install the updates in audit mode. FFU Builder now creates an Apps\LTSCUpdate folder and copies the LCU for Windows 10 LTSC builds still in support to the folder and installs the update in audit mode.
### Fixes an issue with Update ADK failing for non-English languages
Fixed a bug where updating the ADK would fail on non-English installations of Windows due to an assumption that the add/remove programs display information would be in English. Update ADK should correctly identify if the latest release of the ADK is installed and work as expected.
### Added dependency validation when selecting Build USB drive and Copy Drivers
Fixed an issue where a build would begin even though Copy Drivers to USB was set to true but no USB was inserted. FFU Builder will now check before the build gets started and inform the user if no USB drive is inserted.
### Normalizes Windows LTSC release versions to handle driver downloads
Driver downloads would fail if you were building certain LTSC releases due to FFU Builder incorrectly using the LTSC release year instead of the base Windows client version information. When build LTSC FFUs, the drivers should now download as expected.
### Scopes select-all to visible filtered list items in drivers listview
When filtering the drivers listview and selecting all driver models using the select all header checkbox, the select all behavior was selecting everything, even the hidden models in the list. If then selecting Download Selected, FFU Builder would download all models, even hidden ones. This now fixes that issue to only select all visible models and download those selected models.
### Retain downloaded ESD files
FFU Builder will now allow you to retain a downloaded ESD file. There's a new option on the Build tab to Remove Downloaded ESD File(s) which is checked by default to keep with the previous behavior. The intent here is to prevent from having to re-download the ESD file every time you're doing a build. This gives you another option along with Allow VHDX Caching to reduce the need of redownloading media.
### Reduce the size of cached VHDX files
Added some code to reduce the size of the cached VHDX files.
### Include disk size in VHDX cache validation
Prevents reusing cached images when the requested disk size changes. Ensures the disk size property is properly saved and verified against existing cache items to maintain configuration accuracy. This makes it so that if you create a new build with a larger disk size and have Allow VHDX caching selected, it won't use a cached VHDX with a smaller size.
### Enhances file backup and cleanup for cancelled builds
Improves the current-run cleanup mechanism by tracking file downloads explicitly. This ensures that files downloaded via BITS or preserving older timestamps are correctly identified and removed during cleanup. Extends the run manifest schema to support file backups, allowing for safe restoration of pre-existing scripts and configuration files modified during a run. Additional cleanup logic now correctly prunes residual empty directories after tracked files are removed.
### Fixed an issue with arm64 ESD downloads
With the change to how ESD downloads work with 25H2 and the Media Creation Tool, arm64 was broken. This was fixed.
# 2602.1 UI Preview # 2602.1 UI Preview
## What's Changed ## What's Changed
@@ -1,11 +1,3 @@
# Allow Orchestrator.ps1 to override the app list file paths while preserving legacy defaults.
param(
[Parameter()]
[string]$wingetAppsJsonFile = (Join-Path -Path $PSScriptRoot -ChildPath "WinGetWin32Apps.json"),
[Parameter()]
[string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json")
)
function Invoke-Process { function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
param param
@@ -255,6 +247,11 @@ function Install-Applications {
} }
} }
# Define paths for the JSON files
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
# Initialize empty arrays for apps from each source # Initialize empty arrays for apps from each source
$wingetApps = @() $wingetApps = @()
$userApps = @() $userApps = @()
@@ -289,9 +286,9 @@ if ($wingetApps.Count -gt 0) {
Install-Applications -apps $wingetApps Install-Applications -apps $wingetApps
} }
# Read the configured BYO app list file if it exists # Read the UserAppList.json file if it exists
if (Test-Path -Path $userAppsJsonFile) { if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..." Write-Host "Processing UserAppList.json..."
try { try {
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json $userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userContent -is [array]) { if ($userContent -is [array]) {
@@ -299,19 +296,19 @@ if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps." Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
} }
elseif ($userContent) { elseif ($userContent) {
$userApps = @($userContent) $userApps = @($userContent) # Ensure it's an array
Write-Host "Found 1 user-defined app." Write-Host "Found 1 user-defined app."
} }
else { else {
Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid." Write-Host "UserAppList.json is empty or invalid."
} }
} }
catch { catch {
Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_" Write-Error "Failed to read or parse UserAppList.json file: $_"
} }
} }
else { else {
Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping." Write-Host "UserAppList.json file not found. Skipping."
} }
# Install User apps if any were found # Install User apps if any were found
@@ -28,23 +28,6 @@ Write-Host "---------------------------------------------------" -ForegroundColo
# Define the path to the scripts # Define the path to the scripts
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
# Resolve the configured BYO app list path for runtime orchestration.
$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (Test-Path -Path $appInstallConfigPath) {
try {
$appInstallConfig = Get-Content -Path $appInstallConfigPath -Raw | ConvertFrom-Json
if ($null -ne $appInstallConfig -and $appInstallConfig.PSObject.Properties.Match('UserAppListPath').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($appInstallConfig.UserAppListPath)) {
$userAppsJsonFile = $appInstallConfig.UserAppListPath
Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile"
}
}
catch {
Write-Host "Failed to parse AppInstallConfig.json. Falling back to default BYO app list path."
}
}
# Define the list of scripts to run # Define the list of scripts to run
$scriptList = @( $scriptList = @(
"Install-LTSCUpdate.ps1", "Install-LTSCUpdate.ps1",
@@ -68,6 +51,7 @@ foreach ($script in $scriptList) {
switch ($script) { switch ($script) {
"Install-Win32Apps.ps1" { "Install-Win32Apps.ps1" {
$wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json" $wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) { if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) {
$shouldRun = $false $shouldRun = $false
} }
@@ -85,15 +69,10 @@ foreach ($script in $scriptList) {
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script " -ForegroundColor Yellow Write-Host " Running script: $script " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish. # Run script and wait for it to finish
if ($script -eq "Install-Win32Apps.ps1") {
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
}
else {
& $scriptFile & $scriptFile
} }
} }
}
# Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present # Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present
$appsScriptFile = Join-Path -Path $scriptPath -ChildPath "Invoke-AppsScript.ps1" $appsScriptFile = Join-Path -Path $scriptPath -ChildPath "Invoke-AppsScript.ps1"
File diff suppressed because it is too large Load Diff
+4 -134
View File
@@ -46,8 +46,7 @@ $script:uiState = [PSCustomObject]@{
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null; pollTimer = $null;
currentBuildProcess = $null; currentBuildProcess = $null;
lastConfigFilePath = $null; lastConfigFilePath = $null
loadedDeviceNamingMode = $null
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
@@ -56,10 +55,7 @@ $script:uiState = [PSCustomObject]@{
lastSortProperty = $null; lastSortProperty = $null;
lastSortAscending = $true; lastSortAscending = $true;
isBuilding = $false; isBuilding = $false;
isCleanupRunning = $false; isCleanupRunning = $false
isFluentSupported = $false;
deviceNamingModeWasExplicitlyChanged = $false;
suppressDeviceNamingChangeTracking = $false
}; };
Defaults = @{}; Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log" LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
@@ -124,9 +120,6 @@ $reader = New-Object System.IO.StringReader($xamlString)
$xmlReader = [System.Xml.XmlReader]::Create($reader) $xmlReader = [System.Xml.XmlReader]::Create($reader)
$window = [Windows.Markup.XamlReader]::Load($xmlReader) $window = [Windows.Markup.XamlReader]::Load($xmlReader)
# Apply Fluent theme before the window renders (requires PowerShell 7.5+ / .NET 9+)
Initialize-FluentTheme -Window $window -ThemeMode "System" -State $script:uiState
$window.Add_Loaded({ $window.Add_Loaded({
# Pass the state object to all initialization functions # Pass the state object to all initialization functions
$script:uiState.Window = $window $script:uiState.Window = $window
@@ -136,9 +129,6 @@ $window.Add_Loaded({
Initialize-DynamicUIElements -State $script:uiState Initialize-DynamicUIElements -State $script:uiState
Register-EventHandlers -State $script:uiState Register-EventHandlers -State $script:uiState
# Populate the Home page build and release status after the window initializes
Start-HomeStatusRefresh -State $script:uiState
# Attempt automatic load of previous environment (silent) # Attempt automatic load of previous environment (silent)
try { try {
Invoke-AutoLoadPreviousEnvironment -State $script:uiState Invoke-AutoLoadPreviousEnvironment -State $script:uiState
@@ -400,11 +390,8 @@ $script:uiState.Controls.btnRun.Add_Click({
# Not currently building: start a new build # Not currently building: start a new build
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
# Switch to Monitor page via navigation # Switch to Monitor Tab
$monitorIndex = 8 # Monitor is the 9th item (index 8) in the navigation list $script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
if ($null -ne $script:uiState.Controls.lstNavigation) {
$script:uiState.Controls.lstNavigation.SelectedIndex = $monitorIndex
}
# Clear previous log data and reset autoscroll # Clear previous log data and reset autoscroll
if ($null -ne $script:uiState.Data.logData) { if ($null -ne $script:uiState.Data.logData) {
@@ -428,123 +415,6 @@ $script:uiState.Controls.btnRun.Add_Click({
return 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
}
if ($config.CopyUnattend -and $config.InjectUnattend) {
[System.Windows.MessageBox]::Show("Copy Unattend.xml and Inject Unattend.xml cannot both be selected. Choose only one unattend delivery method.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: choose only one unattend delivery method."
return
}
if ($config.CopyUnattend -or $config.InjectUnattend) {
$selectedUnattendArch = if ($config.WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
$selectedUnattendSourcePath = if ($selectedUnattendArch -eq 'arm64') {
[string]$config.UnattendArm64FilePath
}
else {
[string]$config.UnattendX64FilePath
}
if ([string]::IsNullOrWhiteSpace($selectedUnattendSourcePath)) {
[System.Windows.MessageBox]::Show("Select a valid $selectedUnattendArch unattend XML file before using Copy Unattend.xml or Inject Unattend.xml.", "Unattend File Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file path required."
return
}
if (-not (Test-Path -Path $selectedUnattendSourcePath -PathType Leaf)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file was not found:`n$selectedUnattendSourcePath", "Unattend File Missing", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file missing."
return
}
$selectedUnattendFileInfo = Get-Item -Path $selectedUnattendSourcePath -ErrorAction SilentlyContinue
if (($null -eq $selectedUnattendFileInfo) -or ($selectedUnattendFileInfo.Length -le 0)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file is empty:`n$selectedUnattendSourcePath", "Unattend File Empty", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file empty."
return
}
}
if ($config.DeviceNamingMode -eq 'Prompt') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Prompt for Device Name'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prompt naming requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Template') {
if ([string]::IsNullOrWhiteSpace([string]$config.DeviceNameTemplate)) {
[System.Windows.MessageBox]::Show("Specify a device name before using 'Specify Device Name'.", "Device Name Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: device name required."
return
}
if (-not ($config.CopyUnattend -or $config.InjectUnattend)) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml or Inject Unattend.xml before using 'Specify Device Name'.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend delivery method required for device naming."
return
}
$templateWithoutSupportedVariables = ([string]$config.DeviceNameTemplate) -replace '(?i)%serial%', ''
if ($templateWithoutSupportedVariables -match '%') {
[System.Windows.MessageBox]::Show("Only the %serial% device name variable is supported.", "Unsupported Device Name Variable", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unsupported device name variable."
return
}
if ($config.InjectUnattend -and (-not $config.CopyUnattend) -and ([string]$config.DeviceNameTemplate -match '(?i)%serial%')) {
[System.Windows.MessageBox]::Show("The %serial% device name variable is only supported when Copy Unattend.xml is selected.", "Unsupported Inject Unattend Setting", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: %serial% requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Prefixes') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify a list of Prefixes'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes require Copy Unattend.xml."
return
}
$hasSavedPrefixesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNamePrefixesPath) -and (Test-Path -Path $config.DeviceNamePrefixesPath -PathType Leaf)
if ((($null -eq $config.DeviceNamePrefixes) -or ($config.DeviceNamePrefixes.Count -eq 0)) -and -not $hasSavedPrefixesPath) {
[System.Windows.MessageBox]::Show("Enter at least one prefix or choose a valid prefixes file before using 'Specify a list of Prefixes'.", "Prefixes Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes required."
return
}
}
elseif ($config.DeviceNamingMode -eq 'SerialComputerNames') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify Serial to Device Name Mapping'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping requires Copy Unattend.xml."
return
}
$hasSavedSerialComputerNamesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNameSerialComputerNamesPath) -and (Test-Path -Path $config.DeviceNameSerialComputerNamesPath -PathType Leaf)
if ((($null -eq $config.DeviceNameSerialComputerNames) -or ($config.DeviceNameSerialComputerNames.Count -eq 0)) -and -not $hasSavedSerialComputerNamesPath) {
[System.Windows.MessageBox]::Show("Enter CSV content or choose a valid 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
}
}
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json" $configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
# Sort top-level keys alphabetically for consistent output # Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{} $sortedConfig = [ordered]@{}
File diff suppressed because it is too large Load Diff
+43 -3
View File
@@ -3,8 +3,11 @@ param (
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\', [string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
[string]$WindowsArch = 'x64', [string]$WindowsArch = 'x64',
[bool]$CopyPEDrivers = $false, [bool]$CopyPEDrivers = $false,
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso", [string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log" [string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
[bool]$Capture,
[bool]$Deploy = $true
) )
function WriteLog($LogText) { function WriteLog($LogText) {
@@ -74,7 +77,12 @@ function Invoke-Process {
} }
function New-PEMedia { function New-PEMedia {
param () param (
[Parameter()]
[bool]$Capture,
[Parameter()]
[bool]$Deploy
)
#Need to use the Demployment and Imaging tools environment to create winPE media #Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE" $WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -107,6 +115,7 @@ function New-PEMedia {
"en-us\WinPE-Scripting_en-us.cab", "en-us\WinPE-Scripting_en-us.cab",
"WinPE-PowerShell.cab", "WinPE-PowerShell.cab",
"en-us\WinPE-PowerShell_en-us.cab", "en-us\WinPE-PowerShell_en-us.cab",
"WinPE-SecureBootCmdlets.cab",
"WinPE-StorageWMI.cab", "WinPE-StorageWMI.cab",
"en-us\WinPE-StorageWMI_en-us.cab", "en-us\WinPE-StorageWMI_en-us.cab",
"WinPE-DismCmdlets.cab", "WinPE-DismCmdlets.cab",
@@ -127,6 +136,17 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete" 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" WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
WriteLog 'Copy complete' WriteLog 'Copy complete'
@@ -141,7 +161,11 @@ function New-PEMedia {
} }
WriteLog "Adding drivers complete" WriteLog "Adding drivers complete"
} }
# $WinPEISOName = 'WinPE_FFU_Deploy.iso'
$WinPEISOFile = $DeployISO $WinPEISOFile = $DeployISO
# $Deploy = $false
}
WriteLog 'Dismounting WinPE media' WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete' WriteLog 'Dismount complete'
@@ -156,15 +180,31 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile" 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 # & "$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($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'){ 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 Invoke-Process $OSCDIMG $OSCDIMGArgs
WriteLog "ISO created successfully" WriteLog "ISO created successfully"
WriteLog "Cleaning up $WinPEFFUPath" WriteLog "Cleaning up $WinPEFFUPath"
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete' WriteLog 'Cleanup complete'
} }
New-PEMedia if($Capture){
New-PEMedia -Capture $Capture
}
if($Deploy){
New-PEMedia -Deploy $Deploy
}
+5 -3
View File
@@ -85,10 +85,12 @@ graph TD
subgraph "VM-Based Capture (-InstallApps)" subgraph "VM-Based Capture (-InstallApps)"
direction LR direction LR
BB[Create Hyper-V VM from VHDX]; BB[Create Hyper-V VM from VHDX];
BB --> BE["Start VM: Boots to Audit Mode"]; BB --> BC["Create WinPE Capture Media iso"];
BC --> BD[Configure network share for capture];
BD --> BE["Start VM: Boots to Audit Mode"];
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down]; BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
BF --> BG[Host optimizes and remounts VHDX]; BF --> BG[VM reboots from Capture Media];
BG --> BH["DISM captures FFU directly from host-mounted VHDX"]; BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
end end
subgraph "Direct VHDX Capture" subgraph "Direct VHDX Capture"
@@ -6,9 +6,11 @@ function Invoke-FFUPostBuildCleanup {
[string]$AppsPath, [string]$AppsPath,
[string]$DriversPath, [string]$DriversPath,
[string]$FFUCapturePath, [string]$FFUCapturePath,
[string]$CaptureISOPath,
[string]$DeployISOPath, [string]$DeployISOPath,
[string]$AppsISOPath, [string]$AppsISOPath,
[string]$KBPath, [string]$KBPath,
[bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false, [bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false, [bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false, [bool]$RemoveDrivers = $false,
@@ -20,9 +22,13 @@ function Invoke-FFUPostBuildCleanup {
$originalProgressPreference = $ProgressPreference $originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
try { try {
WriteLog "CommonCleanup: Starting cleanup (DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)." WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
# Primary ISO paths (new naming/location) # 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)) { if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
WriteLog "CommonCleanup: Removing $DeployISOPath" WriteLog "CommonCleanup: Removing $DeployISOPath"
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" } try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
@@ -33,6 +39,11 @@ function Invoke-FFUPostBuildCleanup {
} }
# Legacy / root-level WinPE ISOs (pattern-based) # 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) { if ($RemoveDeployISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object { 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)" } 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)" }
@@ -166,175 +166,6 @@ function ConvertTo-SurfaceComparableName {
return $value.Trim().ToUpperInvariant() return $value.Trim().ToUpperInvariant()
} }
function ConvertTo-SurfaceHtmlText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$HtmlFragment
)
# Normalize HTML fragments from the Learn table into plain text values.
$textValue = $HtmlFragment -replace '<br\s*/?>', ' '
$textValue = $textValue -replace '<[^>]+>', ' '
$textValue = [System.Net.WebUtility]::HtmlDecode($textValue)
$textValue = $textValue -replace '\s+', ' '
return $textValue.Trim()
}
function ConvertTo-SurfaceDownloadCenterLink {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LinkValue
)
# Normalize Learn links down to the canonical Download Center details URL.
$decodedLink = [System.Net.WebUtility]::HtmlDecode($LinkValue).Trim()
if ([string]::IsNullOrWhiteSpace($decodedLink)) {
return $null
}
if ($decodedLink.StartsWith('/')) {
$decodedLink = "https://www.microsoft.com$decodedLink"
}
$downloadCenterMatch = [regex]::Match(
$decodedLink,
'https://www\.microsoft\.com(?:/en-us)?/download/details\.aspx\?id=\d+',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if (-not $downloadCenterMatch.Success) {
return $null
}
return ($downloadCenterMatch.Value -replace '/en-us/', '/')
}
function Get-SurfaceDriverModelIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$url = 'https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates'
$minimumExpectedModelCount = 10
# Load the cached model list first to keep Microsoft model discovery fast.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if (@($cache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($(@($cache.ModelIndex).Count) models)."
return @($cache.ModelIndex)
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to the Learn source. Error: $($_.Exception.Message)"
}
try {
# Download the Learn article that now contains the authoritative Surface package table.
WriteLog "Surface cache: Downloading Microsoft model index from $url"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
# Parse each table row and keep only Download Center package links.
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
$models = [System.Collections.Generic.List[pscustomobject]]::new()
$seenModelKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(.*?)\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 2) {
continue
}
$rowLabel = ConvertTo-SurfaceHtmlText -HtmlFragment $cellMatches[0].Groups[1].Value
if ([string]::IsNullOrWhiteSpace($rowLabel) -or $rowLabel -notmatch '(?i)^Surface') {
continue
}
$downloadCellContent = $cellMatches[1].Groups[1].Value
$linkMatches = [regex]::Matches(
$downloadCellContent,
'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
[System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
foreach ($linkMatch in $linkMatches) {
$modelName = ConvertTo-SurfaceHtmlText -HtmlFragment $linkMatch.Groups[2].Value
$modelLink = ConvertTo-SurfaceDownloadCenterLink -LinkValue $linkMatch.Groups[1].Value
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($modelLink)) {
continue
}
$modelKey = "$modelName`n$modelLink"
if (-not $seenModelKeys.Add($modelKey)) {
continue
}
$models.Add([pscustomobject]@{
Make = 'Microsoft'
Model = $modelName
Link = $modelLink
})
}
}
if ($models.Count -eq 0) {
throw "No Microsoft driver models were found in the Learn table."
}
if ($models.Count -lt $minimumExpectedModelCount) {
WriteLog "Surface cache: Warning - Learn parsing returned only $($models.Count) Microsoft model entries."
}
else {
WriteLog "Surface cache: Parsed $($models.Count) Microsoft model entries from Learn."
}
# Save the refreshed model list into the shared cache for both UI and CLI use.
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 "Surface cache: Failed to build Microsoft model list from Learn. Error: $($_.Exception.Message)"
# Fall back to the last cached model list even if it is stale when the live request fails.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$staleCache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
if (@($staleCache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using stale Microsoft model list ($(@($staleCache.ModelIndex).Count) models) because the live Learn request failed."
return @($staleCache.ModelIndex)
}
}
}
catch {
WriteLog "Surface cache: Failed to load stale Microsoft model list fallback. Error: $($_.Exception.Message)"
}
throw "Failed to retrieve Microsoft Surface models."
}
}
function Get-SurfaceSystemSkuReferenceIndex { function Get-SurfaceSystemSkuReferenceIndex {
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -755,9 +586,6 @@ Export-ModuleMember -Function `
Import-SurfaceDriverIndexCache, ` Import-SurfaceDriverIndexCache, `
Save-SurfaceDriverIndexCache, ` Save-SurfaceDriverIndexCache, `
ConvertTo-SurfaceComparableName, ` ConvertTo-SurfaceComparableName, `
ConvertTo-SurfaceHtmlText, `
ConvertTo-SurfaceDownloadCenterLink, `
Get-SurfaceDriverModelIndex, `
Get-SurfaceSystemSkuReferenceIndex, ` Get-SurfaceSystemSkuReferenceIndex, `
Get-SurfaceDownloadCenterDetails, ` Get-SurfaceDownloadCenterDetails, `
Get-SurfaceSystemSkuListForMicrosoftDriver Get-SurfaceSystemSkuListForMicrosoftDriver
@@ -161,7 +161,6 @@ function Invoke-ParallelProcessing {
ApplicationItemData = $currentItem ApplicationItemData = $currentItem
AppListJsonPath = $localJobArgs['AppListJsonPath'] AppListJsonPath = $localJobArgs['AppListJsonPath']
AppsPath = $localJobArgs['AppsPath'] AppsPath = $localJobArgs['AppsPath']
UserAppListPath = $localJobArgs['UserAppListPath']
OrchestrationPath = $localJobArgs['OrchestrationPath'] OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch'] WindowsArch = $localJobArgs['WindowsArch']
@@ -358,8 +358,6 @@ function Start-WingetAppDownloadTask {
[string]$AppListJsonPath, [string]$AppListJsonPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$AppsPath, [string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$OrchestrationPath, [string]$OrchestrationPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -381,11 +379,11 @@ function Start-WingetAppDownloadTask {
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)." WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
try { try {
# Resolve the BYO app list path so duplicate checks honor custom file names. # Define paths
$userAppListPath = if (-not [string]::IsNullOrWhiteSpace($UserAppListPath)) { $UserAppListPath } else { Join-Path -Path $AppsPath -ChildPath "UserAppList.json" } $userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
$appFound = $false $appFound = $false
# 1. Check the configured BYO app list and content # 1. Check UserAppList.json and content
if (Test-Path -Path $userAppListPath) { if (Test-Path -Path $userAppListPath) {
try { try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json $userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
@@ -726,8 +724,6 @@ function Get-Apps {
[string]$AppList, [string]$AppList,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$AppsPath, [string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$WindowsArch, [string]$WindowsArch,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -791,7 +787,6 @@ function Get-Apps {
# CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false # CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false
$taskArguments = @{ $taskArguments = @{
AppsPath = $AppsPath AppsPath = $AppsPath
UserAppListPath = $UserAppListPath
AppListJsonPath = $AppList AppListJsonPath = $AppList
OrchestrationPath = $OrchestrationPath OrchestrationPath = $OrchestrationPath
WindowsArch = $WindowsArch WindowsArch = $WindowsArch
@@ -27,22 +27,6 @@ function Update-BYOAppsActionButtonsState {
} }
} }
# Function to resolve the configured BYO app list path
function Get-BYOApplicationListPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
# Fall back to the legacy default path when the textbox is empty.
if (-not [string]::IsNullOrWhiteSpace($State.Controls.txtUserAppListPath.Text)) {
return $State.Controls.txtUserAppListPath.Text
}
return (Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json')
}
# Function to remove all selected BYO applications # Function to remove all selected BYO applications
function Remove-SelectedBYOApplications { function Remove-SelectedBYOApplications {
[CmdletBinding()] [CmdletBinding()]
@@ -92,10 +76,10 @@ function Remove-SelectedBYOApplications {
} }
# Ask user if they want to save the changes # Ask user if they want to save the changes
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to the configured BYO app list now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question) $result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq 'Yes') { if ($result -eq 'Yes') {
$userAppListPath = Get-BYOApplicationListPath -State $State $userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
Save-BYOApplicationList -Path $userAppListPath -State $State Save-BYOApplicationList -Path $userAppListPath -State $State
} }
} }
@@ -182,7 +166,6 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes # Refresh the ListView to show the changes
$listView.Items.Refresh() $listView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $listView
# Reset state # Reset state
$State.Data.editingBYOApplication = $null $State.Data.editingBYOApplication = $null
@@ -213,7 +196,6 @@ function Add-BYOApplication {
CopyStatus = "" CopyStatus = ""
} }
$listView.Items.Add($application) $listView.Items.Add($application)
Request-ListViewColumnAutoResize -ListView $listView
} }
# Clear form and update button states for both add and update operations # Clear form and update button states for both add and update operations
@@ -287,7 +269,6 @@ function Add-AppsScriptVariable {
} }
$State.Data.appsScriptVariablesDataList.Add($newItem) $State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear() $State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear() $State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state # Update the header checkbox state
@@ -314,7 +295,6 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove) $State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
} }
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
@@ -411,24 +391,18 @@ function Invoke-CopyBYOApps {
) )
$localAppsPath = $State.Controls.txtApplicationPath.Text $localAppsPath = $State.Controls.txtApplicationPath.Text
$userAppListPath = Get-BYOApplicationListPath -State $State $userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
$listView = $State.Controls.lstApplications $listView = $State.Controls.lstApplications
try { try {
# Ensure the configured BYO app list folder exists before writing the manifest. # Ensure items are sorted by current priority before saving
$userAppListDirectory = Split-Path -Path $userAppListPath -Parent # Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList
if (-not [string]::IsNullOrWhiteSpace($userAppListDirectory) -and -not (Test-Path -Path $userAppListDirectory -PathType Container)) {
New-Item -Path $userAppListDirectory -ItemType Directory -Force | Out-Null
}
# Ensure items are sorted by current priority before saving.
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList.
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes $applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8 $applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
WriteLog "Successfully updated BYO app list at $userAppListPath with all applications from the UI." WriteLog "Successfully updated UserAppList.json with all applications from the UI."
} }
catch { catch {
$errorMessage = "Failed to update BYO app list at $($userAppListPath): $_" $errorMessage = "Failed to update UserAppList.json: $_"
WriteLog $errorMessage WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) [System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
return return
@@ -25,6 +25,7 @@ function Get-UIConfig {
else { $null } else { $null }
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
CompactOS = $State.Controls.chkCompactOS.IsChecked CompactOS = $State.Controls.chkCompactOS.IsChecked
@@ -36,23 +37,15 @@ function Get-UIConfig {
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
DeviceNamingMode = Get-ConfiguredDeviceNamingMode -State $State
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 CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
FFUPrefix = $State.Controls.txtVMNamePrefix.Text FFUPrefix = $State.Controls.txtVMNamePrefix.Text
@@ -61,7 +54,6 @@ function Get-UIConfig {
InstallOffice = $State.Controls.chkInstallOffice.IsChecked InstallOffice = $State.Controls.chkInstallOffice.IsChecked
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
ISOPath = $State.Controls.txtISOPath.Text ISOPath = $State.Controls.txtISOPath.Text
WindowsMediaSource = if ($null -ne $State.Controls.rbProvideISO -and $State.Controls.rbProvideISO.IsChecked) { "Provide Windows ISO" } else { "Download Windows ESD" }
LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content
# Make = $null # Make = $null
MediaType = $State.Controls.cmbMediaType.SelectedItem MediaType = $State.Controls.cmbMediaType.SelectedItem
@@ -91,6 +83,7 @@ function Get-UIConfig {
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
ShareName = $State.Controls.txtShareName.Text
UpdateADK = $State.Controls.chkUpdateADK.IsChecked UpdateADK = $State.Controls.chkUpdateADK.IsChecked
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
@@ -100,13 +93,14 @@ function Get-UIConfig {
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
UserAppListPath = $State.Controls.txtUserAppListPath.Text UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json"
USBDriveList = @{} USBDriveList = @{}
Username = $State.Controls.txtUsername.Text
Threads = [int]$State.Controls.txtThreads.Text Threads = [int]$State.Controls.txtThreads.Text
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text 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 Verbose = $State.Controls.chkVerbose.IsChecked
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
VMLocation = $State.Controls.txtVMLocation.Text VMLocation = $State.Controls.txtVMLocation.Text
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') { VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Text $State.Controls.txtCustomVMSwitchName.Text
@@ -418,6 +412,7 @@ function Select-VMSwitchFromConfig {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch $State.Controls.txtCustomVMSwitchName.Text = $configSwitch
$State.Data.customVMSwitchName = $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." WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
} }
} }
@@ -432,19 +427,12 @@ function Update-UIFromConfig {
WriteLog "Applying loaded configuration to the UI." WriteLog "Applying loaded configuration to the UI."
# Apply theme mode from config (must be done before other controls load for proper styling)
if ($null -ne $ConfigContent.PSObject.Properties.Item('ThemeMode') -and $State.Flags.isFluentSupported) {
$configTheme = $ConfigContent.ThemeMode
if ($configTheme -in @("Light", "Dark", "System")) {
Initialize-FluentTheme -Window $State.Window -ThemeMode $configTheme -State $State
Set-UIValue -ControlName 'cmbThemeMode' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'ThemeMode' -State $State
}
}
# Update Build tab values # Update Build tab values
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State 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 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -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 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -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 Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
@@ -456,58 +444,19 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State 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 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -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 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -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 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) # USB Drive Modification group (Build Tab)
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State 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 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -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', 'SerialComputerNames')) {
$loadedDeviceNamingMode = $candidateDeviceNamingMode
}
}
$displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode
}
else {
'None'
}
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) # Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State 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 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -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 Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
@@ -516,30 +465,17 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
# Hyper-V Settings # Hyper-V Settings
Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent 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 '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 '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 Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State 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 '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 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 # Windows Settings
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
# Load Windows Media Source setting
if ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsMediaSource')) {
if ($ConfigContent.WindowsMediaSource -eq 'Provide Windows ISO') {
$State.Controls.rbProvideISO.IsChecked = $true
}
else {
$State.Controls.rbDownloadESD.IsChecked = $true
}
}
# Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC) # Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC)
if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) { if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) {
@@ -649,7 +585,6 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State
Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State
Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State
Set-UIValue -ControlName 'txtUserAppListPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UserAppListPath' -State $State
# Handle AppsScriptVariables # Handle AppsScriptVariables
$appsScriptVarsKeyExists = $false $appsScriptVarsKeyExists = $false
@@ -713,7 +648,6 @@ function Update-UIFromConfig {
} }
# Update the ListView's ItemsSource after populating the data list # Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -782,7 +716,6 @@ function Update-UIFromConfig {
} }
} }
$State.Controls.lstUSBDrives.Items.Refresh() $State.Controls.lstUSBDrives.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state # Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader $headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -843,7 +776,6 @@ function Update-UIFromConfig {
} }
} }
$State.Controls.lstAdditionalFFUs.Items.Refresh() $State.Controls.lstAdditionalFFUs.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs $headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
@@ -903,7 +835,7 @@ function Invoke-RestoreDefaults {
$rootPath = $State.FFUDevelopmentPath $rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings # Normalize potential array values to single strings
function Get-PathScalar { function Normalize-PathScalar {
param([object]$value) param([object]$value)
if ($null -eq $value) { return $null } if ($null -eq $value) { return $null }
if ($value -is [System.Array]) { if ($value -is [System.Array]) {
@@ -918,20 +850,21 @@ function Invoke-RestoreDefaults {
} }
$appsPath = Join-Path $rootPath 'Apps' $appsPath = Join-Path $rootPath 'Apps'
$driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text $driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
if ([string]::IsNullOrWhiteSpace($driversRaw)) { if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers' $driversPath = Join-Path $rootPath 'Drivers'
} }
else { else {
$driversPath = $driversRaw $driversPath = $driversRaw
} }
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text $ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw } $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' $deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
$appsISOPath = Join-Path $rootPath 'Apps.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 (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 (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?"
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning") $result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
if ($result -ne [System.Windows.MessageBoxResult]::Yes) { if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
WriteLog "RestoreDefaults: User cancelled." WriteLog "RestoreDefaults: User cancelled."
@@ -967,9 +900,11 @@ function Invoke-RestoreDefaults {
-AppsPath $appsPath ` -AppsPath $appsPath `
-DriversPath $driversPath ` -DriversPath $driversPath `
-FFUCapturePath $ffuCapturePath ` -FFUCapturePath $ffuCapturePath `
-CaptureISOPath $captureISOPath `
-DeployISOPath $deployISOPath ` -DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath ` -AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') ` -KBPath (Join-Path $rootPath 'KB') `
-RemoveCaptureISO:$true `
-RemoveDeployISO:$true ` -RemoveDeployISO:$true `
-RemoveAppsISO:$true ` -RemoveAppsISO:$true `
-RemoveDrivers:$true ` -RemoveDrivers:$true `
@@ -1104,7 +1039,6 @@ function Import-ConfigSupplementalAssets {
}) })
} }
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray() $State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true $loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) { if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible' $State.Controls.wingetSearchPanel.Visibility = 'Visible'
@@ -1239,7 +1173,6 @@ function Import-ConfigSupplementalAssets {
} }
} }
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) { if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels $headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
@@ -23,11 +23,10 @@ function Get-LenovoDriversModelList {
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. # 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). # 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. # 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.
# 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
# $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers # Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie $Headers["Cookie"] = $lenovoCookie
@@ -15,8 +15,100 @@ function Get-MicrosoftDriversModelList {
[string]$DriversFolder [string]$DriversFolder
) )
# Keep the UI signature unchanged while using the shared Learn-based source. $url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder) $models = @()
# Load cached model list first (Source B) to keep the UI fast.
# The cache is refreshed automatically when missing or invalid.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath).LastWriteTime).TotalDays
if ($cacheAgeDays -lt 7) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.ModelIndex -and $cache.ModelIndex.Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($($cache.ModelIndex.Count) models)."
return @($cache.ModelIndex)
}
}
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to online parse. Error: $($_.Exception.Message)"
}
try {
WriteLog "Getting Surface driver information from $url"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
WriteLog "Parsing web content for models and download links"
$html = $webContent.Content
$divPattern = '<div[^>]*class="selectable-content-options__option-content(?: ocHidden)?"[^>]*>(.*?)</div>'
$divMatches = [regex]::Matches($html, $divPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($divMatch in $divMatches) {
$divContent = $divMatch.Groups[1].Value
$tablePattern = '<table[^>]*>(.*?)</table>'
$tableMatches = [regex]::Matches($divContent, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($tableMatch in $tableMatches) {
$tableContent = $tableMatch.Groups[1].Value
$rowPattern = '<tr[^>]*>(.*?)</tr>'
$rowMatches = [regex]::Matches($tableContent, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellPattern = '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>'
$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 = '<a[^>]+href="([^"]+)"[^>]*>'
# Change linkPattern to match https://www.microsoft.com/download/details.aspx?id=
$linkPattern = '<a[^>]+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."
}
} }
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel) # Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask { function Save-MicrosoftDriversTask {
@@ -373,7 +373,6 @@ function Search-DriverModels {
} }
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering. # The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0 $filteredCount = 0
if ($null -ne $collectionView) { if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ } foreach ($item in $collectionView) { $filteredCount++ }
@@ -716,7 +715,6 @@ function Import-DriversJson {
# Update the UI and apply any existing filter # Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated" $message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -788,7 +786,6 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter # Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = "" $State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) { if ($State.Data.allDriverModels.Count -gt 0) {
@@ -5,341 +5,6 @@
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. 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 Get-SelectedDeviceNamingMode {
param([PSCustomObject]$State)
if ($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) {
return 'Prompt'
}
if ($true -eq $State.Controls.rbDeviceNamingTemplate.IsChecked) {
return 'Template'
}
if ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked) {
return 'Prefixes'
}
if ($true -eq $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked) {
return 'SerialComputerNames'
}
return 'None'
}
function Set-DeviceNamingMode {
param(
[PSCustomObject]$State,
[ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[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'
$State.Controls.rbDeviceNamingSerialComputerNames.IsChecked = $Mode -eq 'SerialComputerNames'
}
function Set-DeviceNamingModeState {
param(
[PSCustomObject]$State,
[ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[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)
if ($null -eq $State.Controls.txtDeviceNamePrefixes) {
return @()
}
return @(
$State.Controls.txtDeviceNamePrefixes.Text -split "\r?\n" |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim() }
)
}
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,
[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 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)
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
return $null
}
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,
[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,
[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 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)
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
$requiresCopiedUnattend = ($selectedDeviceNamingMode -in @('Prompt', 'Prefixes', 'SerialComputerNames')) -or $deviceNameTemplateUsesSerialToken
if ($isCopyUnattendSelected -and $isInjectUnattendSelected) {
if ($requiresCopiedUnattend) {
$State.Controls.chkInjectUnattend.IsChecked = $false
$isInjectUnattendSelected = $false
}
else {
$State.Controls.chkCopyUnattend.IsChecked = $false
$isCopyUnattendSelected = $false
}
}
if ($requiresCopiedUnattend) {
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.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
}
function Register-EventHandlers { function Register-EventHandlers {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Registering UI event handlers..." WriteLog "Registering UI event handlers..."
@@ -359,7 +24,7 @@ function Register-EventHandlers {
# Define a handler to validate pasted text, ensuring it's only integers # Define a handler to validate pasted text, ensuring it's only integers
$integerPastingHandler = { $integerPastingHandler = {
param($eventSource, $pastingEventArgs) param($sender, $pastingEventArgs)
if ($pastingEventArgs.DataObject.GetDataPresent([string])) { if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
$pastedText = $pastingEventArgs.DataObject.GetData([string]) $pastedText = $pastingEventArgs.DataObject.GetData([string])
# Check if the pasted text consists ONLY of one or more digits. # Check if the pasted text consists ONLY of one or more digits.
@@ -422,132 +87,6 @@ function Register-EventHandlers {
}) })
} }
# Navigation Sidebar Event Handlers
# Main navigation list - switches content pages based on selected nav item
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
$selectedIndex = $eventSource.SelectedIndex
if ($selectedIndex -lt 0) { return }
# Clear Settings selection when main nav is used
if ($null -ne $localState.Controls.lstNavSettings) {
$localState.Controls.lstNavSettings.SelectedIndex = -1
}
# Hide all content pages
foreach ($page in $localState.Controls.navigationPages) {
if ($null -ne $page) { $page.Visibility = 'Collapsed' }
}
if ($null -ne $localState.Controls.pageSettings) {
$localState.Controls.pageSettings.Visibility = 'Collapsed'
}
# Show the selected page
if ($selectedIndex -lt $localState.Controls.navigationPages.Count) {
$localState.Controls.navigationPages[$selectedIndex].Visibility = 'Visible'
}
# Update the shared page title to match the selected navigation item
if ($null -ne $localState.Controls.txtPageTitle) {
$selectedNavigationItem = $eventSource.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$localState.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
}
})
}
# Settings navigation item
if ($null -ne $State.Controls.lstNavSettings) {
$State.Controls.lstNavSettings.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
if ($eventSource.SelectedIndex -lt 0) { return }
# Clear main navigation selection
if ($null -ne $localState.Controls.lstNavigation) {
$localState.Controls.lstNavigation.SelectedIndex = -1
}
# Hide all content pages
foreach ($page in $localState.Controls.navigationPages) {
if ($null -ne $page) { $page.Visibility = 'Collapsed' }
}
# Show Settings page
if ($null -ne $localState.Controls.pageSettings) {
$localState.Controls.pageSettings.Visibility = 'Visible'
}
# Update the shared page title to match the selected navigation item
if ($null -ne $localState.Controls.txtPageTitle) {
$selectedNavigationItem = $eventSource.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$localState.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$localState.Controls.txtPageTitle.Text = 'Settings'
}
}
})
}
# Hyperlink navigation handlers for Home page links
$hyperlinkNames = @(
'linkQuickStart',
'linkDocs',
'linkGitHub',
'linkReleases',
'linkChangelog',
'linkVideo1',
'linkDiscussion1',
'linkDiscussion2',
'linkDiscussion3',
'linkDiscussion4',
'linkDiscussion5',
'linkDiscussions'
)
foreach ($linkName in $hyperlinkNames) {
$link = $State.Window.FindName($linkName)
if ($null -ne $link) {
$link.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
}
}
# Settings Page Event Handlers
# Theme mode selector - switches between Light, Dark, and System Fluent themes
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
if (-not $localState.Flags.isFluentSupported) {
return
}
$selectedTheme = $eventSource.SelectedItem
if (-not [string]::IsNullOrWhiteSpace($selectedTheme)) {
Initialize-FluentTheme -Window $window -ThemeMode $selectedTheme -State $localState
}
})
}
# Build Tab Event Handlers # Build Tab Event Handlers
$State.Controls.btnBrowseFFUDevPath.Add_Click({ $State.Controls.btnBrowseFFUDevPath.Add_Click({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
@@ -555,34 +94,7 @@ function Register-EventHandlers {
$localState = $window.Tag $localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path" $selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) { 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 $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
}
if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath) -or $currentUnattendArm64FilePath -ieq $previousDefaultUnattendArm64FilePath) {
$localState.Controls.txtUnattendArm64FilePath.Text = $newDefaultUnattendArm64FilePath
}
Import-DeviceNamePrefixesFromConfiguredPath -State $localState
Import-SerialComputerNamesFromConfiguredPath -State $localState
Update-DeviceNamingControls -State $localState
} }
}) })
@@ -596,246 +108,19 @@ function Register-EventHandlers {
} }
}) })
$State.Controls.rbDeviceNamingNone.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.rbDeviceNamingPrompt.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.rbDeviceNamingTemplate.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.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)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
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)
$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.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 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
}
})
$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)
$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.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 Serial Computer Names CSV Mapping 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)
$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 # Build USB Drive Settings Event Handlers
# The USB Expander is always visible; the checkbox controls child settings only
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({ $State.Controls.chkBuildUSBDriveEnable.Add_Checked({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Visible'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true $localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true
}) })
$State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({ $State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Collapsed'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false $localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false
$localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false $localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false
$localState.Controls.lstUSBDrives.Items.Clear() $localState.Controls.lstUSBDrives.Items.Clear()
@@ -935,7 +220,6 @@ function Register-EventHandlers {
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force $driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
$localState.Controls.lstUSBDrives.Items.Add($driveObject) $localState.Controls.lstUSBDrives.Items.Add($driveObject)
} }
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) { if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
$localState.Controls.lstUSBDrives.SelectedIndex = 0 $localState.Controls.lstUSBDrives.SelectedIndex = 0
} }
@@ -969,30 +253,42 @@ function Register-EventHandlers {
}) })
# Hyper-V tab event handlers # 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({ $State.Controls.cmbVMSwitchName.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs) param($eventSource, $selectionChangedEventArgs)
# The state object is available via the parent window's Tag property # The state object is available via the parent window's Tag property
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
Update-VMNetworkingControls -State $localState $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
}
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 when user edits it while 'Other' is selected # 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
}
})
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({ $State.Controls.txtCustomVMSwitchName.Add_LostFocus({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
@@ -1003,31 +299,12 @@ function Register-EventHandlers {
}) })
# Windows Settings tab Event Handlers # Windows Settings tab Event Handlers
# Windows Media Source radio buttons $State.Controls.txtISOPath.Add_TextChanged({
if ($null -ne $State.Controls.rbProvideISO) { param($eventSource, $textChangedEventArgs)
$State.Controls.rbProvideISO.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) { return }
$localState = $window.Tag $localState = $window.Tag
$localState.Controls.isoPathPanel.Visibility = 'Visible' Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState
# Use a placeholder .iso path to trigger ISO mode even before a real path is provided
$isoPath = $localState.Controls.txtISOPath.Text
if ([string]::IsNullOrWhiteSpace($isoPath)) {
$isoPath = 'placeholder.iso'
}
Get-WindowsSettingsCombos -isoPath $isoPath -State $localState
}) })
$State.Controls.rbProvideISO.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) { return }
$localState = $window.Tag
$localState.Controls.isoPathPanel.Visibility = 'Collapsed'
$localState.Controls.txtISOPath.Text = ''
Get-WindowsSettingsCombos -isoPath '' -State $localState
})
}
$State.Controls.cmbWindowsRelease.Add_SelectionChanged({ $State.Controls.cmbWindowsRelease.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs) param($eventSource, $selectionChangedEventArgs)
@@ -1037,13 +314,7 @@ function Register-EventHandlers {
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) { if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value $selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value
} }
# Determine ISO path based on radio button state Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $localState.Controls.txtISOPath.Text -State $localState
$isoPath = ''
if ($null -ne $localState.Controls.rbProvideISO -and $localState.Controls.rbProvideISO.IsChecked) {
$isoPath = $localState.Controls.txtISOPath.Text
if ([string]::IsNullOrWhiteSpace($isoPath)) { $isoPath = 'placeholder.iso' }
}
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $isoPath -State $localState
Update-WindowsSkuCombo -State $localState Update-WindowsSkuCombo -State $localState
Update-WindowsArchCombo -State $localState Update-WindowsArchCombo -State $localState
@@ -1164,18 +435,10 @@ function Register-EventHandlers {
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select Winget AppList File" -Filter "JSON files (*.json)|*.json" -AllowNewFile $selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select AppList.json File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
if ($selectedPath) { $localState.Controls.txtAppListJsonPath.Text = $selectedPath } if ($selectedPath) { $localState.Controls.txtAppListJsonPath.Text = $selectedPath }
}) })
$State.Controls.btnBrowseUserAppListPath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select BYO AppList File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
if ($selectedPath) { $localState.Controls.txtUserAppListPath.Text = $selectedPath }
})
$State.Controls.btnBrowseAppSource.Add_Click({ $State.Controls.btnBrowseAppSource.Add_Click({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
@@ -1203,23 +466,17 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
# Default the save dialog to the configured BYO app list path. $initialDir = $localState.Controls.txtApplicationPath.Text
$currentPath = $localState.Controls.txtUserAppListPath.Text
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath } if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
$fileName = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Leaf } else { "UserAppList.json" }
$savePath = Invoke-BrowseAction -Type 'SaveFile' ` $savePath = Invoke-BrowseAction -Type 'SaveFile' `
-Title "Save BYO App List" ` -Title "Save Application List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" ` -Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir ` -InitialDirectory $initialDir `
-FileName $fileName ` -FileName "UserAppList.json" `
-DefaultExt ".json" -DefaultExt ".json"
if ($savePath) { if ($savePath) { Save-BYOApplicationList -Path $savePath -State $localState }
$localState.Controls.txtUserAppListPath.Text = $savePath
Save-BYOApplicationList -Path $savePath -State $localState
}
}) })
$State.Controls.btnLoadBYOApplications.Add_Click({ $State.Controls.btnLoadBYOApplications.Add_Click({
@@ -1227,18 +484,15 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
# Default the import dialog to the configured BYO app list path. $initialDir = $localState.Controls.txtApplicationPath.Text
$currentPath = $localState.Controls.txtUserAppListPath.Text
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath } if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
$loadPath = Invoke-BrowseAction -Type 'OpenFile' ` $loadPath = Invoke-BrowseAction -Type 'OpenFile' `
-Title "Import BYO App List" ` -Title "Import Application List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" ` -Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir -InitialDirectory $initialDir
if ($loadPath) { if ($loadPath) {
$localState.Controls.txtUserAppListPath.Text = $loadPath
Import-BYOApplicationList -Path $loadPath -State $localState Import-BYOApplicationList -Path $loadPath -State $localState
Update-CopyButtonState -State $localState Update-CopyButtonState -State $localState
} }
@@ -13,97 +13,6 @@
This module is critical for setting up the initial state of the application window when it first loads. This module is critical for setting up the initial state of the application window when it first loads.
#> #>
function Initialize-FluentTheme {
param(
[Parameter(Mandatory = $true)]
[System.Windows.Window]$Window,
[Parameter(Mandatory = $false)]
[string]$ThemeMode = "System",
[Parameter(Mandatory = $false)]
[PSCustomObject]$State
)
# Check if the current .NET runtime supports Window.ThemeMode (requires .NET 9+ / PowerShell 7.5+)
$themeModeProperty = [System.Windows.Window].GetProperty("ThemeMode")
if ($null -eq $themeModeProperty) {
WriteLog "Fluent theme not available. Window.ThemeMode requires PowerShell 7.5+ (.NET 9+). Using default Aero2 theme."
if ($null -ne $State) {
$State.Flags.isFluentSupported = $false
}
# Still create tooltip styles for non-Fluent mode so Tag-to-ToolTip binding works
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
$newStyle = New-Object System.Windows.Style($controlType)
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles created for non-Fluent mode."
return
}
# Mark Fluent as supported in state
if ($null -ne $State) {
$State.Flags.isFluentSupported = $true
}
# Resolve the ThemeMode enum value using reflection to avoid compile-time experimental attribute issues
$themeModeType = [System.Windows.Window].GetProperty("ThemeMode").PropertyType
$themeModeValue = $null
switch ($ThemeMode) {
"Light" { $themeModeValue = $themeModeType::Light }
"Dark" { $themeModeValue = $themeModeType::Dark }
"System" { $themeModeValue = $themeModeType::System }
default { $themeModeValue = $themeModeType::System }
}
# Apply the Fluent theme mode to the window
$themeModeProperty.SetValue($Window, $themeModeValue)
WriteLog "Applied Fluent theme: $ThemeMode"
# Re-create implicit tooltip styles with BasedOn pointing to the Fluent base style
# This preserves the Tag-to-ToolTip binding while inheriting Fluent visual styling
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
# Get the Fluent base style that was loaded by ThemeMode
$fluentBaseStyle = $Window.TryFindResource($controlType)
# Create a new implicit style with ToolTip binding
$newStyle = New-Object System.Windows.Style($controlType)
if ($null -ne $fluentBaseStyle) {
$newStyle.BasedOn = $fluentBaseStyle
}
# Add the ToolTip setter that binds to the Tag property
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
# Remove any existing implicit style for this type before adding the new one
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles updated with Fluent base styles."
}
function Initialize-UIControls { function Initialize-UIControls {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing UI control references..." WriteLog "Initializing UI control references..."
@@ -112,9 +21,6 @@ function Initialize-UIControls {
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease') $State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion') $State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
$State.Controls.txtISOPath = $window.FindName('txtISOPath') $State.Controls.txtISOPath = $window.FindName('txtISOPath')
$State.Controls.rbDownloadESD = $window.FindName('rbDownloadESD')
$State.Controls.rbProvideISO = $window.FindName('rbProvideISO')
$State.Controls.isoPathPanel = $window.FindName('isoPathPanel')
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO') $State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch') $State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang') $State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
@@ -164,10 +70,8 @@ function Initialize-UIControls {
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion') $State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel') $State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel') $State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
$State.Controls.userAppListPathPanel = $window.FindName('userAppListPathPanel')
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath') $State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath') $State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
$State.Controls.btnBrowseUserAppListPath = $window.FindName('btnBrowseUserAppListPath')
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps') $State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel') $State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel') $State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
@@ -205,47 +109,29 @@ function Initialize-UIControls {
$State.Controls.txtStatus = $window.FindName('txtStatus') $State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar') $State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus') $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.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName') $State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath') $State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate') $State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation') $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.txtThreads = $window.FindName('txtThreads')
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority') $State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives') $State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS') $State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize') $State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching') $State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia') $State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend') $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')
$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.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot') $State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend') $State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG') $State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO') $State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO') $State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers') $State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU') $State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
@@ -273,7 +159,6 @@ function Initialize-UIControls {
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU') $State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath') $State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath') $State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
$State.Controls.txtUserAppListPath = $window.FindName('txtUserAppListPath')
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers') $State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers') $State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM') $State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
@@ -295,59 +180,11 @@ function Initialize-UIControls {
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults') $State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig') $State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
# Home page # Monitor Tab
$State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue') $State.Controls.MainTabControl = $window.FindName('MainTabControl')
$State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue') $State.Controls.MonitorTab = $window.FindName('MonitorTab')
$State.Controls.txtHomeReleaseStatusValue = $window.FindName('txtHomeReleaseStatusValue')
$State.Controls.spHomeReleaseNotesSections = $window.FindName('spHomeReleaseNotesSections')
$State.Controls.ellipseHomeDiskSpaceStatus = $window.FindName('ellipseHomeDiskSpaceStatus')
$State.Controls.txtHomeDiskSpaceStatusValue = $window.FindName('txtHomeDiskSpaceStatusValue')
$State.Controls.ellipseHomeHyperVStatus = $window.FindName('ellipseHomeHyperVStatus')
$State.Controls.txtHomeHyperVStatusValue = $window.FindName('txtHomeHyperVStatusValue')
$State.Controls.txtHomeDiscussionsStatusValue = $window.FindName('txtHomeDiscussionsStatusValue')
$State.Controls.tbDiscussion1 = $window.FindName('tbDiscussion1')
$State.Controls.linkDiscussion1 = $window.FindName('linkDiscussion1')
$State.Controls.runDiscussion1 = $window.FindName('runDiscussion1')
$State.Controls.tbDiscussion2 = $window.FindName('tbDiscussion2')
$State.Controls.linkDiscussion2 = $window.FindName('linkDiscussion2')
$State.Controls.runDiscussion2 = $window.FindName('runDiscussion2')
$State.Controls.tbDiscussion3 = $window.FindName('tbDiscussion3')
$State.Controls.linkDiscussion3 = $window.FindName('linkDiscussion3')
$State.Controls.runDiscussion3 = $window.FindName('runDiscussion3')
$State.Controls.tbDiscussion4 = $window.FindName('tbDiscussion4')
$State.Controls.linkDiscussion4 = $window.FindName('linkDiscussion4')
$State.Controls.runDiscussion4 = $window.FindName('runDiscussion4')
$State.Controls.tbDiscussion5 = $window.FindName('tbDiscussion5')
$State.Controls.linkDiscussion5 = $window.FindName('linkDiscussion5')
$State.Controls.runDiscussion5 = $window.FindName('runDiscussion5')
$State.Controls.tbDiscussionsLink = $window.FindName('tbDiscussionsLink')
$State.Controls.linkDiscussions = $window.FindName('linkDiscussions')
# Settings page
$State.Controls.cmbThemeMode = $window.FindName('cmbThemeMode')
# Shared page shell
$State.Controls.txtPageTitle = $window.FindName('txtPageTitle')
# Navigation controls
$State.Controls.lstNavigation = $window.FindName('lstNavigation')
$State.Controls.lstNavSettings = $window.FindName('lstNavSettings')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput') $State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Content pages (for navigation visibility toggling)
$State.Controls.navigationPages = @(
$window.FindName('pageHome'),
$window.FindName('pageHyperV'),
$window.FindName('pageWindows'),
$window.FindName('pageUpdates'),
$window.FindName('pageApplications'),
$window.FindName('pageOffice'),
$window.FindName('pageDrivers'),
$window.FindName('pageBuild'),
$window.FindName('pageMonitor')
)
$State.Controls.pageSettings = $window.FindName('pageSettings')
# Initialize and bind the log data collection # Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string] $State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData $State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
@@ -368,11 +205,19 @@ function Initialize-VMSwitchData {
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null $State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) { if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0 $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' $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
} }
else { else {
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other' $State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
} }
} }
@@ -388,6 +233,8 @@ function Initialize-UIDefaults {
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate $State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation $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.txtThreads.Text = $State.Defaults.generalDefaults.Threads
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority $State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives $State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
@@ -397,8 +244,7 @@ function Initialize-UIDefaults {
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize $State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching $State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend $State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
$State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath $State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia $State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia $State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia $State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
@@ -406,22 +252,8 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot $State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend $State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG $State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$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)
$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.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO $State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers $State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU $State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
@@ -429,6 +261,7 @@ function Initialize-UIDefaults {
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates $State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
$State.Controls.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD $State.Controls.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD
$State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose $State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose
$State.Controls.usbSection.Visibility = if ($State.Controls.chkBuildUSBDriveEnable.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' } $State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked $State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked $State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
@@ -437,9 +270,7 @@ function Initialize-UIDefaults {
Update-BitsPrioritySetting -State $State Update-BitsPrioritySetting -State $State
# Hyper-V Settings defaults from General Defaults # Hyper-V Settings defaults from General Defaults
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
Initialize-VMSwitchData -State $State Initialize-VMSwitchData -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB $State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB $State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors $State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
@@ -448,12 +279,7 @@ function Initialize-UIDefaults {
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() }) $State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
# Populate Windows Release, Version, and SKU comboboxes # Populate Windows Release, Version, and SKU comboboxes
# Initialize Windows settings combos based on media source mode Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State
$initIsoPath = $State.Defaults.windowsSettingsDefaults.DefaultISOPath
if ($null -ne $State.Controls.rbProvideISO -and -not $State.Controls.rbProvideISO.IsChecked) {
$initIsoPath = ''
}
Get-WindowsSettingsCombos -isoPath $initIsoPath -State $State
# Windows Settings tab defaults # Windows Settings tab defaults
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages $State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
@@ -479,7 +305,6 @@ function Initialize-UIDefaults {
$State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps $State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps
$State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath $State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
$State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath $State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath
$State.Controls.txtUserAppListPath.Text = $State.Defaults.generalDefaults.UserAppListPath
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps $State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps $State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
@@ -520,31 +345,6 @@ function Initialize-UIDefaults {
# Set initial state for InstallApps checkbox based on updates # Set initial state for InstallApps checkbox based on updates
Update-InstallAppsState -State $State Update-InstallAppsState -State $State
# Set default theme mode and disable if Fluent is not supported
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.SelectedItem = "System"
if (-not $State.Flags.isFluentSupported) {
$State.Controls.cmbThemeMode.IsEnabled = $false
$State.Controls.cmbThemeMode.Tag = "Fluent theme requires PowerShell 7.5+ (.NET 9+). Best experience on PowerShell 7.6+ (.NET 10)."
}
}
# Set default navigation selection to Home and initialize the shared page title
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.SelectedIndex = 0
# Keep the shell header aligned with the selected navigation item on first render
if ($null -ne $State.Controls.txtPageTitle) {
$selectedNavigationItem = $State.Controls.lstNavigation.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$State.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$State.Controls.txtPageTitle.Text = 'Home'
}
}
}
# Set initial state for Office panel visibility # Set initial state for Office panel visibility
Update-OfficePanelVisibility -State $State Update-OfficePanelVisibility -State $State
@@ -553,40 +353,15 @@ function Initialize-UIDefaults {
# Set initial state for BYO Apps copy button # Set initial state for BYO Apps copy button
Update-CopyButtonState -State $State Update-CopyButtonState -State $State
# Apply accent color to primary action button only (per Windows design guidance)
if ($State.Flags.isFluentSupported) {
try {
$State.Controls.btnRun = $State.Window.FindName('btnRun')
if ($null -ne $State.Controls.btnRun) {
# Use SetResourceReference for live accent color updates when user changes Windows theme
$State.Controls.btnRun.SetResourceReference(
[System.Windows.Controls.Control]::BackgroundProperty,
[System.Windows.SystemColors]::AccentColorBrushKey
)
$State.Controls.btnRun.Foreground = [System.Windows.Media.Brushes]::White
}
}
catch {
WriteLog "Could not apply accent color to Build FFU button: $($_.Exception.Message)"
}
}
} }
function Initialize-DynamicUIElements { function Initialize-DynamicUIElements {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing dynamic UI elements (Grids, Columns)..." WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
# Get the Fluent base style for ListViewItem in GridView mode
# Must use GridViewItemContainerStyleKey (not the generic ListViewItem type key) because the
# generic Fluent ListViewItem style has a template without GridViewRowPresenter, which breaks
# column-based rendering and causes items to display their ToString() representation.
$listViewItemBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.GridView]::GridViewItemContainerStyleKey)
# Driver Models ListView setup # Driver Models ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleDriverModels.BasedOn = $listViewItemBaseStyle }
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels $State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
@@ -616,16 +391,12 @@ 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 # Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView $wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first $State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleWingetResults.BasedOn = $listViewItemBaseStyle }
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults $State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
@@ -673,11 +444,9 @@ function Initialize-DynamicUIElements {
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay $binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding) $comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
# Create a style to disable the ComboBox for 'msstore' source, inheriting the Fluent base style # Create a style to disable the ComboBox for 'msstore' source
$comboBoxStyle = New-Object System.Windows.Style $comboBoxStyle = New-Object System.Windows.Style
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox] $comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
$comboBoxBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.ComboBox])
if ($null -ne $comboBoxBaseStyle) { $comboBoxStyle.BasedOn = $comboBoxBaseStyle }
$dataTrigger = New-Object System.Windows.DataTrigger $dataTrigger = New-Object System.Windows.DataTrigger
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source") $dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
@@ -778,16 +547,12 @@ 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 # BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView $byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView $State.Controls.lstApplications.View = $byoAppsGridView
# Set ListViewItem style to stretch content horizontally # Set ListViewItem style to stretch content horizontally
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleBYOApps.BasedOn = $listViewItemBaseStyle }
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps $State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
@@ -804,16 +569,12 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left 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 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 # Apps Script Variables ListView setup
# Bind ItemsSource to the data list # Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAppsScriptVars.BasedOn = $listViewItemBaseStyle }
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars $State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
@@ -860,9 +621,6 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep apps script variable columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -879,7 +637,6 @@ function Initialize-DynamicUIElements {
# USB Drives ListView setup # USB Drives ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleUSBDrives.BasedOn = $listViewItemBaseStyle }
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives $State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
@@ -936,9 +693,6 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep USB drive columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -946,7 +700,6 @@ function Initialize-DynamicUIElements {
# Additional FFUs ListView setup # Additional FFUs ListView setup
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAdditionalFFUs.BasedOn = $listViewItemBaseStyle }
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs $State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
@@ -985,9 +738,6 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep additional FFU columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -20,7 +20,6 @@ function Update-ListViewPriorities {
} }
} }
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
} }
# Function to move selected item to the top # Function to move selected item to the top
@@ -134,7 +133,6 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) { if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue $itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change $ListView.Items.Refresh() # Refresh the view to show the change
Request-ListViewColumnAutoResize -ListView $ListView
} }
else { else {
# Log if item not found (for debugging) # Log if item not found (for debugging)
@@ -345,7 +343,6 @@ function Add-SelectableGridViewColumn {
# Create the "Select All" CheckBox for the header # Create the "Select All" CheckBox for the header
$headerCheckBox = New-Object System.Windows.Controls.CheckBox $headerCheckBox = New-Object System.Windows.Controls.CheckBox
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center $headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
$headerCheckBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
# Store header metadata, including whether select-all should only affect visible rows. # Store header metadata, including whether select-all should only affect visible rows.
$headerTagObject = [PSCustomObject]@{ $headerTagObject = [PSCustomObject]@{
@@ -415,33 +412,11 @@ function Add-SelectableGridViewColumn {
} }
}) })
# Wrap the header checkbox in a stretched container so it centers the same way as row cells.
# Apply a small left inset to mirror the Fluent ListViewItem content padding used by data rows.
$headerBorder = New-Object System.Windows.Controls.Border
$headerBorder.Padding = New-Object System.Windows.Thickness(12, 0, 0, 0)
$headerBorder.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerBorder.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid = New-Object System.Windows.Controls.Grid
$headerGrid.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerGrid.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid.Children.Add($headerCheckBox) | Out-Null
$headerBorder.Child = $headerGrid
# Use an explicit GridViewColumnHeader so we can remove the default header padding
# and control the checkbox alignment explicitly.
$selectableHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$selectableHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Stretch
$selectableHeader.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Stretch
$selectableHeader.Padding = New-Object System.Windows.Thickness(0)
$selectableHeader.Margin = New-Object System.Windows.Thickness(0)
$selectableHeader.Content = $headerBorder
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox $State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'." WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn $selectableColumn = New-Object System.Windows.Controls.GridViewColumn
$selectableColumn.Header = $selectableHeader $selectableColumn.Header = $headerCheckBox
$selectableColumn.Width = $ColumnWidth $selectableColumn.Width = $ColumnWidth
$cellTemplate = New-Object System.Windows.DataTemplate $cellTemplate = New-Object System.Windows.DataTemplate
@@ -496,209 +471,6 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'." 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 to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState { function Update-SelectAllHeaderCheckBoxState {
param( param(
@@ -778,7 +550,6 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property # Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected $selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state # Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName] $headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -949,8 +720,6 @@ function Invoke-ListViewSort {
$newView.Filter = $existingFilter $newView.Filter = $existingFilter
} }
} }
Request-ListViewColumnAutoResize -ListView $listView
} }
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -1173,9 +942,6 @@ function Invoke-BrowseAction {
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) { if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory $dialog.InitialDirectory = $InitialDirectory
} }
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
$dialog.FileName = $FileName
}
if ($dialog.ShowDialog()) { if ($dialog.ShowDialog()) {
return $dialog.FileName return $dialog.FileName
} }
@@ -1254,7 +1020,6 @@ function Clear-ListViewContent {
} }
$ListViewControl.Items.Refresh() $ListViewControl.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes # Clear any specified textboxes
if ($null -ne $TextBoxesToClear) { if ($null -ne $TextBoxesToClear) {
@@ -59,7 +59,6 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object # Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray() $State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text # Update status text
$statusText = "" $statusText = ""
@@ -109,28 +108,20 @@ function Save-WingetList {
}) })
} }
# Default the save dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$fileName = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Leaf } else { "AppList.json" }
$sfd = New-Object System.Windows.Forms.SaveFileDialog $sfd = New-Object System.Windows.Forms.SaveFileDialog
$sfd.Filter = "JSON files (*.json)|*.json" $sfd.Filter = "JSON files (*.json)|*.json"
$sfd.Title = "Save Winget App List" $sfd.Title = "Save App List"
$sfd.InitialDirectory = $initialDirectory # Correctly get the path from the UI control via the State object
$sfd.FileName = $fileName $sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text
$sfd.FileName = "AppList.json"
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8 $appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
$State.Controls.txtAppListJsonPath.Text = $sfd.FileName [System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information")
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error saving Winget app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error")
} }
} }
@@ -141,17 +132,11 @@ function Import-WingetList {
[psobject]$State [psobject]$State
) )
try { try {
# Default the import dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$ofd = New-Object System.Windows.Forms.OpenFileDialog $ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "JSON files (*.json)|*.json" $ofd.Filter = "JSON files (*.json)|*.json"
$ofd.Title = "Import Winget App List" $ofd.Title = "Import App List"
$ofd.InitialDirectory = $initialDirectory # Correctly get the path from the UI control via the State object
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json $importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
@@ -159,16 +144,16 @@ function Import-WingetList {
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new() $newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
if ($null -ne $importedAppsData.apps) { if ($null -ne $importedAppsData.apps) {
# Get default architecture from the UI for fallback. # Get default architecture from the UI for fallback
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem $defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
foreach ($appInfo in $importedAppsData.apps) { foreach ($appInfo in $importedAppsData.apps) {
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } } $arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
$newAppListForItemsSource.Add([PSCustomObject]@{ $newAppListForItemsSource.Add([PSCustomObject]@{
IsSelected = $true IsSelected = $true # Imported apps are marked as selected
Name = $appInfo.name Name = $appInfo.name
Id = $appInfo.id Id = $appInfo.id
Version = "" Version = "" # Will be populated when searching or if data exists
Source = $appInfo.source Source = $appInfo.source
Architecture = $arch Architecture = $arch
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" } AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
@@ -179,14 +164,12 @@ function Import-WingetList {
} }
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray() $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") [System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
} }
} }
+5 -789
View File
@@ -106,20 +106,16 @@ function Get-GeneralDefaults {
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers" $peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM" $vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU" $ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
$unattendPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "unattend"
$officePath = Join-Path -Path $appsPath -ChildPath "Office" $officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json" $appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.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"
return [PSCustomObject]@{ return [PSCustomObject]@{
# Build Tab Defaults # Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}" CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath FFUCaptureLocation = $ffuCapturePath
ShareName = "FFUCaptureShare"
Username = "ffu_user"
Threads = 5 Threads = 5
BitsPriority = 'Normal' BitsPriority = 'Normal'
MaxUSBDrives = 5 MaxUSBDrives = 5
@@ -127,6 +123,7 @@ function Get-GeneralDefaults {
CompactOS = $true CompactOS = $true
Optimize = $true Optimize = $true
AllowVHDXCaching = $false AllowVHDXCaching = $false
CreateCaptureMedia = $true
CreateDeploymentMedia = $true CreateDeploymentMedia = $true
Verbose = $false Verbose = $false
AllowExternalHardDiskMedia = $false AllowExternalHardDiskMedia = $false
@@ -137,15 +134,8 @@ function Get-GeneralDefaults {
CopyUnattend = $false CopyUnattend = $false
CopyPPKG = $false CopyPPKG = $false
InjectUnattend = $false InjectUnattend = $false
UnattendX64FilePath = $unattendX64FilePath
UnattendArm64FilePath = $unattendArm64FilePath
DeviceNamingMode = 'None'
DeviceNameTemplate = ''
DeviceNamePrefixesPath = $deviceNamePrefixesPath
DeviceNamePrefixes = @()
DeviceNameSerialComputerNamesPath = $deviceNameSerialComputerNamesPath
DeviceNameSerialComputerNames = @()
CleanupAppsISO = $true CleanupAppsISO = $true
CleanupCaptureISO = $true
CleanupDeployISO = $true CleanupDeployISO = $true
CleanupDrivers = $false CleanupDrivers = $false
RemoveFFU = $false RemoveFFU = $false
@@ -153,7 +143,7 @@ function Get-GeneralDefaults {
RemoveUpdates = $false RemoveUpdates = $false
RemoveDownloadedESD = $true RemoveDownloadedESD = $true
# Hyper-V Settings Defaults # Hyper-V Settings Defaults
EnableVMNetworking = $false VMHostIPAddress = ""
DiskSizeGB = 50 DiskSizeGB = 50
MemoryGB = 4 MemoryGB = 4
Processors = 4 Processors = 4
@@ -173,7 +163,6 @@ function Get-GeneralDefaults {
InstallApps = $false InstallApps = $false
ApplicationPath = $appsPath ApplicationPath = $appsPath
AppListJsonPath = $appListJsonPath AppListJsonPath = $appListJsonPath
UserAppListPath = $userAppListPath
InstallWingetApps = $false InstallWingetApps = $false
BringYourOwnApps = $false BringYourOwnApps = $false
# M365 Apps/Office Tab Defaults # M365 Apps/Office Tab Defaults
@@ -277,7 +266,6 @@ function Update-AdditionalFFUList {
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null } foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder." WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
} }
Request-ListViewColumnAutoResize -ListView $listView
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs $headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
@@ -317,7 +305,6 @@ function Update-ApplicationPanelVisibility {
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' } $subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility $State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility $State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
$State.Controls.userAppListPathPanel.Visibility = $subOptionVisibility
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility $State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
$State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility $State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility
$State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility $State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility
@@ -481,777 +468,6 @@ function Update-DriverDownloadPanelVisibility {
} }
} }
# --------------------------------------------------------------------------
# SECTION: Home Page Build Status
# --------------------------------------------------------------------------
# Function to normalize release strings so local builds and GitHub tags compare consistently
function ConvertTo-NormalizedReleaseVersion {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Version)) {
return $null
}
$normalizedVersion = $Version.Trim().ToLowerInvariant()
$normalizedVersion = $normalizedVersion -replace '^[v]', ''
return $normalizedVersion
}
# Function to read the current FFU Builder build from the main build script
function Get-FFUBuilderCurrentBuild {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$FFUDevelopmentPath
)
$buildScriptPath = Join-Path -Path $FFUDevelopmentPath -ChildPath 'BuildFFUVM.ps1'
if (-not (Test-Path -Path $buildScriptPath)) {
return 'Unknown'
}
try {
$buildScriptContent = Get-Content -Path $buildScriptPath -Raw -ErrorAction Stop
$versionMatch = [regex]::Match($buildScriptContent, '(?m)^\$version\s*=\s*''([^'']+)''')
if ($versionMatch.Success) {
return $versionMatch.Groups[1].Value
}
}
catch {
WriteLog "Unable to read the current FFU Builder build version: $($_.Exception.Message)"
}
return 'Unknown'
}
# Function to query GitHub for the latest published FFU Builder release
function Get-FFUBuilderLatestRelease {
[CmdletBinding()]
param()
$releaseApiUri = 'https://api.github.com/repos/rbalsleyMSFT/FFU/releases/latest'
$releaseHeaders = @{
'User-Agent' = 'FFUBuilderUI'
'Accept' = 'application/vnd.github+json'
}
$releaseResponse = Invoke-RestMethod -Uri $releaseApiUri -Headers $releaseHeaders -TimeoutSec 5 -ErrorAction Stop
$releaseVersion = if (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.tag_name)) {
[string]$releaseResponse.tag_name
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.name)) {
[string]$releaseResponse.name
}
else {
$null
}
return [PSCustomObject]@{
Version = $releaseVersion
HtmlUrl = [string]$releaseResponse.html_url
Body = [string]$releaseResponse.body
}
}
# Function to build a user-friendly release status message for the Home page
function Get-FFUBuilderReleaseStatusMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$CurrentBuild,
[Parameter(Mandatory = $true)]
[string]$LatestRelease
)
# Format the release string for Home page display while keeping compare logic normalized
$displayLatestRelease = if ([string]::IsNullOrWhiteSpace($LatestRelease)) {
$LatestRelease
}
else {
$LatestRelease -replace '^[vV]', ''
}
$normalizedCurrentBuild = ConvertTo-NormalizedReleaseVersion -Version $CurrentBuild
$normalizedLatestRelease = ConvertTo-NormalizedReleaseVersion -Version $LatestRelease
if ([string]::IsNullOrWhiteSpace($normalizedCurrentBuild)) {
return 'Installed build information is unavailable.'
}
if ([string]::IsNullOrWhiteSpace($normalizedLatestRelease)) {
return 'Unable to compare the installed build with the latest release.'
}
if ($normalizedCurrentBuild -eq $normalizedLatestRelease) {
return 'You are running the latest published build.'
}
$currentVersionMatch = [regex]::Match($normalizedCurrentBuild, '^\d+(?:\.\d+){0,3}')
$latestVersionMatch = [regex]::Match($normalizedLatestRelease, '^\d+(?:\.\d+){0,3}')
if ($currentVersionMatch.Success -and $latestVersionMatch.Success) {
try {
$currentVersion = [version]$currentVersionMatch.Value
$latestVersion = [version]$latestVersionMatch.Value
if ($currentVersion -lt $latestVersion) {
return "A newer release is available: $displayLatestRelease."
}
if ($currentVersion -gt $latestVersion) {
return "This build is newer than the latest published release: $displayLatestRelease."
}
}
catch {
WriteLog "Unable to compare FFU Builder release versions numerically: $($_.Exception.Message)"
}
}
return "Installed build $CurrentBuild differs from the latest published release $displayLatestRelease."
}
# Function to normalize a markdown heading for release-notes display
function ConvertTo-ReleaseNotesHeadingText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Line
)
if ([string]::IsNullOrWhiteSpace($Line)) {
return ''
}
$cleanLine = $Line.Trim()
$cleanLine = $cleanLine -replace '^#+\s*', ''
$cleanLine = [regex]::Replace($cleanLine, '\[([^\]]+)\]\([^)]+\)', '$1')
$cleanLine = $cleanLine -replace '\*\*', ''
$cleanLine = $cleanLine -replace '`', ''
return $cleanLine.Trim()
}
# Function to clean plain text segments before rendering markdown-aware inlines
function ConvertTo-ReleaseNotesPlainText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Text
)
if ([string]::IsNullOrWhiteSpace($Text)) {
return ''
}
$cleanText = $Text
$cleanText = $cleanText -replace '\*\*', ''
$cleanText = $cleanText -replace '`', ''
return $cleanText
}
# Function to add markdown-aware inline content to a TextBlock
function Add-ReleaseNotesInlinesToTextBlock {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[System.Windows.Controls.TextBlock]$TextBlock,
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Text
)
if ([string]::IsNullOrWhiteSpace($Text)) {
return
}
$matchPattern = '(?<MarkdownLink>\[(?<LinkText>[^\]]+)\]\((?<LinkUrl>https?://[^)\s]+)\))|(?<BareUrl>https?://[^\s)]+)|(?<Bold>\*\*(?<BoldText>.+?)\*\*)'
$currentIndex = 0
foreach ($match in [regex]::Matches($Text, $matchPattern)) {
if ($match.Index -gt $currentIndex) {
$plainText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex, $match.Index - $currentIndex)
if (-not [string]::IsNullOrWhiteSpace($plainText)) {
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($plainText)) | Out-Null
}
}
if ($match.Groups['MarkdownLink'].Success) {
$hyperlink = [System.Windows.Documents.Hyperlink]::new()
$hyperlink.NavigateUri = [System.Uri]$match.Groups['LinkUrl'].Value
$hyperlink.ToolTip = $match.Groups['LinkUrl'].Value
$hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($match.Groups['LinkText'].Value)) | Out-Null
$hyperlink.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
$TextBlock.Inlines.Add($hyperlink) | Out-Null
}
elseif ($match.Groups['BareUrl'].Success) {
$bareUrl = $match.Groups['BareUrl'].Value.TrimEnd('.', ',', ';', ':')
$hyperlink = [System.Windows.Documents.Hyperlink]::new()
$hyperlink.NavigateUri = [System.Uri]$bareUrl
$hyperlink.ToolTip = $bareUrl
$hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($bareUrl)) | Out-Null
$hyperlink.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
$TextBlock.Inlines.Add($hyperlink) | Out-Null
$trailingCharactersLength = $match.Groups['BareUrl'].Value.Length - $bareUrl.Length
if ($trailingCharactersLength -gt 0) {
$trailingCharacters = $match.Groups['BareUrl'].Value.Substring($bareUrl.Length, $trailingCharactersLength)
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($trailingCharacters)) | Out-Null
}
}
elseif ($match.Groups['Bold'].Success) {
$boldRun = [System.Windows.Documents.Run]::new((ConvertTo-ReleaseNotesPlainText -Text $match.Groups['BoldText'].Value))
$boldRun.FontWeight = 'SemiBold'
$TextBlock.Inlines.Add($boldRun) | Out-Null
}
$currentIndex = $match.Index + $match.Length
}
if ($currentIndex -lt $Text.Length) {
$remainingText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex)
if (-not [string]::IsNullOrWhiteSpace($remainingText)) {
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($remainingText)) | Out-Null
}
}
}
# Function to build a formatted UI element for a release-notes section body
function New-ReleaseNotesSectionContent {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Content
)
$contentPanel = New-Object System.Windows.Controls.StackPanel
$contentPanel.Margin = '0,2,0,2'
foreach ($contentLine in ($Content -split "`r?`n")) {
$trimmedLine = $contentLine.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedLine)) {
continue
}
$isFirstRenderedLine = ($contentPanel.Children.Count -eq 0)
$textBlock = New-Object System.Windows.Controls.TextBlock
$textBlock.TextWrapping = 'Wrap'
$textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,12,0,0' }
$lineContent = $trimmedLine
$listItemMatch = [regex]::Match($trimmedLine, '^(?:[-*]|\d+\.)\s+(.+)$')
if ($listItemMatch.Success) {
$textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,10,0,0' }
$textBlock.Inlines.Add([System.Windows.Documents.Run]::new([string][char]0x2022 + ' ')) | Out-Null
$lineContent = $listItemMatch.Groups[1].Value
}
Add-ReleaseNotesInlinesToTextBlock -TextBlock $textBlock -Text $lineContent
$contentPanel.Children.Add($textBlock) | Out-Null
}
if ($contentPanel.Children.Count -eq 0) {
$fallbackTextBlock = New-Object System.Windows.Controls.TextBlock
$fallbackTextBlock.Text = 'No additional details were published for this section.'
$fallbackTextBlock.TextWrapping = 'Wrap'
$fallbackTextBlock.Margin = '0,2,0,0'
$contentPanel.Children.Add($fallbackTextBlock) | Out-Null
}
return $contentPanel
}
# Function to parse the full GitHub release notes into UI sections
function Get-FFUBuilderReleaseNotesSections {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$ReleaseNotesBody
)
$releaseNoteSections = [System.Collections.Generic.List[object]]::new()
if ([string]::IsNullOrWhiteSpace($ReleaseNotesBody)) {
$releaseNoteSections.Add([PSCustomObject]@{
Title = 'Release Notes'
Content = 'No release notes were published for this release.'
UseExpander = $false
IsExpanded = $true
})
return $releaseNoteSections
}
$currentTitle = 'Release Overview'
$currentLines = [System.Collections.Generic.List[string]]::new()
foreach ($releaseNotesLine in ($ReleaseNotesBody -split "`r?`n")) {
$trimmedLine = $releaseNotesLine.Trim()
if ($trimmedLine -match '^#+\s*(.+)$') {
$sectionContent = ($currentLines -join [Environment]::NewLine).Trim()
if (-not [string]::IsNullOrWhiteSpace($sectionContent)) {
$useExpander = (($sectionContent -split "`r?`n").Count -gt 2 -or $sectionContent.Length -gt 220)
$releaseNoteSections.Add([PSCustomObject]@{
Title = $currentTitle
Content = $sectionContent
UseExpander = $useExpander
IsExpanded = ($releaseNoteSections.Count -eq 0)
})
}
$currentTitle = ConvertTo-ReleaseNotesHeadingText -Line $matches[1]
$currentLines = [System.Collections.Generic.List[string]]::new()
continue
}
if ([string]::IsNullOrWhiteSpace($trimmedLine)) {
if ($currentLines.Count -gt 0 -and $currentLines[$currentLines.Count - 1] -ne '') {
$currentLines.Add('')
}
continue
}
$currentLines.Add($trimmedLine)
}
$finalSectionContent = ($currentLines -join [Environment]::NewLine).Trim()
if (-not [string]::IsNullOrWhiteSpace($finalSectionContent)) {
$useExpander = (($finalSectionContent -split "`r?`n").Count -gt 2 -or $finalSectionContent.Length -gt 220)
$releaseNoteSections.Add([PSCustomObject]@{
Title = $currentTitle
Content = $finalSectionContent
UseExpander = $useExpander
IsExpanded = ($releaseNoteSections.Count -eq 0)
})
}
if ($releaseNoteSections.Count -eq 0) {
$releaseNoteSections.Add([PSCustomObject]@{
Title = 'Release Notes'
Content = 'No release notes were published for this release.'
UseExpander = $false
IsExpanded = $true
})
}
return $releaseNoteSections
}
# Function to render formatted release notes into the Home page
function Set-HomeReleaseNotesContent {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$ReleaseNotesBody
)
$releaseNotesPanel = $State.Controls.spHomeReleaseNotesSections
if ($null -eq $releaseNotesPanel) {
return
}
$releaseNotesPanel.Children.Clear()
$releaseNoteSections = @(Get-FFUBuilderReleaseNotesSections -ReleaseNotesBody $ReleaseNotesBody)
foreach ($releaseNoteSection in $releaseNoteSections) {
$sectionContent = New-ReleaseNotesSectionContent -Content $releaseNoteSection.Content
if ($releaseNoteSection.UseExpander) {
$headerTextBlock = New-Object System.Windows.Controls.TextBlock
$headerTextBlock.Text = $releaseNoteSection.Title
$headerTextBlock.TextWrapping = 'Wrap'
$headerTextBlock.FontWeight = 'SemiBold'
$releaseNotesExpander = New-Object System.Windows.Controls.Expander
$releaseNotesExpander.Header = $headerTextBlock
$releaseNotesExpander.IsExpanded = [bool]$releaseNoteSection.IsExpanded
$releaseNotesExpander.Margin = '0,0,0,8'
$releaseNotesExpander.Content = $sectionContent
$releaseNotesPanel.Children.Add($releaseNotesExpander) | Out-Null
}
else {
$releaseNotesSectionPanel = New-Object System.Windows.Controls.StackPanel
$releaseNotesSectionPanel.Margin = '0,0,0,8'
if (-not [string]::IsNullOrWhiteSpace($releaseNoteSection.Title)) {
$titleTextBlock = New-Object System.Windows.Controls.TextBlock
$titleTextBlock.Text = $releaseNoteSection.Title
$titleTextBlock.FontWeight = 'SemiBold'
$titleTextBlock.TextWrapping = 'Wrap'
$releaseNotesSectionPanel.Children.Add($titleTextBlock) | Out-Null
}
$releaseNotesSectionPanel.Children.Add($sectionContent) | Out-Null
$releaseNotesPanel.Children.Add($releaseNotesSectionPanel) | Out-Null
}
}
}
# Function to return a Home page status light brush for environment checks
function Get-HomeStatusBrush {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Green', 'Yellow', 'Red')]
[string]$Level
)
switch ($Level) {
'Green' { return [System.Windows.Media.Brushes]::LimeGreen }
'Yellow' { return [System.Windows.Media.Brushes]::Gold }
'Red' { return [System.Windows.Media.Brushes]::IndianRed }
}
}
# Function to evaluate free disk space on the drive hosting the FFU development path
function Get-FFUBuilderDiskSpaceStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$FFUDevelopmentPath
)
try {
$resolvedPath = if (Test-Path -Path $FFUDevelopmentPath) {
(Resolve-Path -Path $FFUDevelopmentPath -ErrorAction Stop).Path
}
else {
$FFUDevelopmentPath
}
$driveRoot = [System.IO.Path]::GetPathRoot($resolvedPath)
if ([string]::IsNullOrWhiteSpace($driveRoot)) {
throw "Unable to determine a drive root for path $FFUDevelopmentPath"
}
$driveInfo = [System.IO.DriveInfo]::new($driveRoot)
$freeSpaceGb = [math]::Round($driveInfo.AvailableFreeSpace / 1GB, 2)
if ($freeSpaceGb -lt 50) {
return [PSCustomObject]@{
Level = 'Red'
Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder is likely to run out of disk space and should have at least 100 GB free."
}
}
if ($freeSpaceGb -lt 100) {
return [PSCustomObject]@{
Level = 'Yellow'
Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder recommends at least 100 GB free space."
}
}
return [PSCustomObject]@{
Level = 'Green'
Message = "$freeSpaceGb GB free on $driveRoot. Free space is within the recommended range."
}
}
catch {
WriteLog "Unable to determine free disk space for FFUDevelopmentPath: $($_.Exception.Message)"
return [PSCustomObject]@{
Level = 'Red'
Message = 'Unable to determine free disk space for the FFUDevelopmentPath drive.'
}
}
}
# Function to evaluate the local Hyper-V installation state
function Get-FFUBuilderHyperVStatus {
[CmdletBinding()]
param()
try {
$hyperVFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction Stop
switch ([string]$hyperVFeature.State) {
'Enabled' {
return [PSCustomObject]@{
Level = 'Green'
Message = 'Hyper-V is installed and ready.'
}
}
'EnablePending' {
return [PSCustomObject]@{
Level = 'Yellow'
Message = 'Hyper-V is installed, but a reboot is required before it is ready.'
}
}
default {
return [PSCustomObject]@{
Level = 'Red'
Message = "Hyper-V is not installed. Current feature state: $($hyperVFeature.State)."
}
}
}
}
catch {
WriteLog "Unable to determine Hyper-V installation state: $($_.Exception.Message)"
return [PSCustomObject]@{
Level = 'Red'
Message = 'Unable to determine the Hyper-V installation state.'
}
}
}
# Function to update the Home page release status fields
function Update-HomeReleaseStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[string]$CurrentBuild,
[Parameter(Mandatory = $true)]
[string]$LatestRelease,
[Parameter(Mandatory = $true)]
[string]$StatusMessage,
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesBody
)
if ($null -ne $State.Controls.txtHomeCurrentBuildValue) {
$State.Controls.txtHomeCurrentBuildValue.Text = $CurrentBuild
}
if ($null -ne $State.Controls.txtHomeLatestReleaseValue) {
$State.Controls.txtHomeLatestReleaseValue.Text = $LatestRelease
}
if ($null -ne $State.Controls.txtHomeReleaseStatusValue) {
$State.Controls.txtHomeReleaseStatusValue.Text = $StatusMessage
}
# Render the full release notes into structured sections on the Home page
Set-HomeReleaseNotesContent -State $State -ReleaseNotesBody $ReleaseNotesBody
}
# Function to update the Home page environment check fields
function Update-HomeEnvironmentStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[PSCustomObject]$DiskSpaceStatus,
[Parameter(Mandatory = $true)]
[PSCustomObject]$HyperVStatus
)
if ($null -ne $State.Controls.ellipseHomeDiskSpaceStatus) {
$State.Controls.ellipseHomeDiskSpaceStatus.Fill = Get-HomeStatusBrush -Level $DiskSpaceStatus.Level
}
if ($null -ne $State.Controls.txtHomeDiskSpaceStatusValue) {
$State.Controls.txtHomeDiskSpaceStatusValue.Text = $DiskSpaceStatus.Message
}
if ($null -ne $State.Controls.ellipseHomeHyperVStatus) {
$State.Controls.ellipseHomeHyperVStatus.Fill = Get-HomeStatusBrush -Level $HyperVStatus.Level
}
if ($null -ne $State.Controls.txtHomeHyperVStatusValue) {
$State.Controls.txtHomeHyperVStatusValue.Text = $HyperVStatus.Message
}
}
# Function to retrieve latest public GitHub discussions for Home page display
function Get-FFUBuilderLatestDiscussions {
[CmdletBinding()]
param()
$discussionUri = 'https://github.com/rbalsleyMSFT/FFU/discussions'
$discussionHeaders = @{
'User-Agent' = 'FFUBuilderUI'
'Accept' = 'text/html,application/xhtml+xml'
}
$discussionResponse = Invoke-WebRequest -Uri $discussionUri -Headers $discussionHeaders -TimeoutSec 5 -ErrorAction Stop
$discussionContent = [string]$discussionResponse.Content
$latestDiscussions = New-Object System.Collections.Generic.List[PSCustomObject]
$seenDiscussionUrls = @{}
# Parse the raw HTML instead of Invoke-WebRequest Links because GitHub's page structure
# does not reliably surface the discussion topic anchors through the Links collection.
$discussionMatches = [regex]::Matches(
$discussionContent,
'<a[^>]+href="(?<Href>/rbalsleyMSFT/FFU/discussions/(?<Id>\d+))"[^>]*>(?<InnerHtml>.*?)</a>',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
)
foreach ($discussionMatch in $discussionMatches) {
$discussionHref = [string]$discussionMatch.Groups['Href'].Value
$discussionUrl = "https://github.com$discussionHref"
if ($seenDiscussionUrls.ContainsKey($discussionUrl)) {
continue
}
$discussionInnerHtml = [string]$discussionMatch.Groups['InnerHtml'].Value
$discussionTitle = [regex]::Replace($discussionInnerHtml, '<[^>]+>', ' ')
$discussionTitle = [System.Net.WebUtility]::HtmlDecode($discussionTitle)
$discussionTitle = [regex]::Replace($discussionTitle, '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($discussionTitle)) {
continue
}
# Skip links that resolve to comment counts or other numeric-only link text.
if ($discussionTitle -match '^\d+$') {
continue
}
$seenDiscussionUrls[$discussionUrl] = $true
$latestDiscussions.Add([PSCustomObject]@{
Title = $discussionTitle
Url = $discussionUrl
})
if ($latestDiscussions.Count -ge 5) {
break
}
}
return $latestDiscussions
}
# Function to update the Home page discussions card
function Update-HomeDiscussions {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[string]$StatusMessage,
[Parameter(Mandatory = $false)]
[AllowNull()]
[System.Collections.IEnumerable]$Discussions
)
if ($null -ne $State.Controls.txtHomeDiscussionsStatusValue) {
$State.Controls.txtHomeDiscussionsStatusValue.Text = $StatusMessage
}
$discussionItems = @($Discussions)
for ($index = 1; $index -le 5; $index++) {
$container = $State.Controls["tbDiscussion$index"]
$link = $State.Controls["linkDiscussion$index"]
$run = $State.Controls["runDiscussion$index"]
if ($null -eq $container -or $null -eq $link -or $null -eq $run) {
continue
}
if ($index -le $discussionItems.Count -and $null -ne $discussionItems[$index - 1]) {
$discussionItem = $discussionItems[$index - 1]
$run.Text = $discussionItem.Title
$link.NavigateUri = [System.Uri]$discussionItem.Url
$container.Visibility = 'Visible'
}
else {
$run.Text = ''
$link.NavigateUri = [System.Uri]'https://github.com/rbalsleyMSFT/FFU/discussions'
$container.Visibility = 'Collapsed'
}
}
if ($null -ne $State.Controls.tbDiscussionsLink) {
$State.Controls.tbDiscussionsLink.Visibility = 'Visible'
}
}
# Function to populate the Home page build status after the window has rendered
function Start-HomeStatusRefresh {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State
)
# Populate local status checks immediately so Home is useful even before network requests complete
$currentBuild = Get-FFUBuilderCurrentBuild -FFUDevelopmentPath $State.FFUDevelopmentPath
$diskSpaceStatus = Get-FFUBuilderDiskSpaceStatus -FFUDevelopmentPath $State.FFUDevelopmentPath
$hyperVStatus = Get-FFUBuilderHyperVStatus
Update-HomeReleaseStatus -State $State -CurrentBuild $currentBuild -LatestRelease 'Checking GitHub...' -StatusMessage 'Checking whether this build is current...' -ReleaseNotesBody 'Checking latest release notes...'
Update-HomeEnvironmentStatus -State $State -DiskSpaceStatus $diskSpaceStatus -HyperVStatus $hyperVStatus
Update-HomeDiscussions -State $State -StatusMessage 'Checking latest discussions...' -Discussions @()
if ($null -eq $State.Window) {
return
}
# Capture the state values before dispatching to avoid losing them in the deferred callback
$refreshState = $State
$refreshCurrentBuild = $currentBuild
$refreshAction = {
$latestReleaseDisplay = 'Unable to check'
$statusMessage = 'Unable to check the latest release right now. Check GitHub Releases when you are back online.'
$releaseNotesBody = 'Unable to load the latest release notes right now.'
$discussionsStatusMessage = 'Unable to load the latest GitHub discussions right now.'
$latestDiscussions = @()
try {
$latestRelease = Get-FFUBuilderLatestRelease
if ($null -ne $latestRelease -and -not [string]::IsNullOrWhiteSpace($latestRelease.Version)) {
# Strip the GitHub tag prefix so Home shows the same style as the installed build
$latestReleaseDisplay = $latestRelease.Version -replace '^[vV]', ''
$statusMessage = Get-FFUBuilderReleaseStatusMessage -CurrentBuild $refreshCurrentBuild -LatestRelease $latestRelease.Version
$releaseNotesBody = if ([string]::IsNullOrWhiteSpace($latestRelease.Body)) {
'No release notes were published for this release.'
}
else {
$latestRelease.Body
}
}
}
catch {
WriteLog "Unable to retrieve the latest FFU Builder release: $($_.Exception.Message)"
}
try {
$latestDiscussions = @(Get-FFUBuilderLatestDiscussions)
if ($latestDiscussions.Count -gt 0) {
$discussionsStatusMessage = 'Latest public GitHub discussions.'
}
else {
$discussionsStatusMessage = 'No recent public discussion topics were found.'
}
}
catch {
WriteLog "Unable to retrieve the latest FFU Builder discussions: $($_.Exception.Message)"
}
Update-HomeReleaseStatus -State $refreshState -CurrentBuild $refreshCurrentBuild -LatestRelease $latestReleaseDisplay -StatusMessage $statusMessage -ReleaseNotesBody $releaseNotesBody
Update-HomeDiscussions -State $refreshState -StatusMessage $discussionsStatusMessage -Discussions $latestDiscussions
}.GetNewClosure()
# Queue the network checks after the UI renders so startup remains responsive
$null = $State.Window.Dispatcher.BeginInvoke(
[System.Action]$refreshAction,
[System.Windows.Threading.DispatcherPriority]::Background
)
}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# SECTION: Module Export # SECTION: Module Export
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -0,0 +1,4 @@
select disk 0
select partition 3
Assign letter="M"
exit
@@ -0,0 +1,237 @@
$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: $_"
}
@@ -0,0 +1,5 @@
wpeinit
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
powershell -Noprofile -ExecutionPolicy Bypass -File x:\CaptureFFU.ps1
exit
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -1,4 +0,0 @@
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
1 SerialNumber ComputerName
2 ABC12345 CORP-001
3 DEF67890 KIOSK-010
4 XYZ24680 STORE-015
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>*</ComputerName><!--Leave Default will be renamed--> <ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed-->
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone--> <TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
</component> </component>
<!-- Place additional Components Elements and Settings below here: --> <!-- Place additional Components Elements and Settings below here: -->
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>*</ComputerName> <ComputerName>MyComputer</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>*</ComputerName> <ComputerName>MyComputer</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+7 -9
View File
@@ -1,8 +1,4 @@
# Updates **Update 2026-02-04:** [2602.1 UI Preview is now available](https://github.com/rbalsleyMSFT/FFU/releases) - click the link to get the build and check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html)
## 2026-03-16 - [2603.2 Released](https://github.com/rbalsleyMSFT/FFU/releases)
Fixes an issue with devices not booting after applying an FFU. Highly recommended you update today.
# Using Full Flash Update (FFU) files to speed up Windows deployment # Using Full Flash Update (FFU) files to speed up Windows deployment
@@ -26,12 +22,14 @@ The Full-Flash update (FFU) process can automatically download the latest releas
# Getting Started # Getting Started
If you're new to FFU Builder or new to the FFU Builder UI version, check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html). If you're new, check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html). This will be the fastest way to create your first FFU. There's a new [FFU Builder Quickstart Youtube video](https://youtu.be/kOIK5OmDugc) based on the 2602.1 UI Preview release.
This will be the fastest way to create your first FFU. There's a new [FFU Builder Quickstart Youtube video](https://youtu.be/kOIK5OmDugc) based on the 2602.1 UI Preview release. Older Youtube Videos
## Older Youtube Videos
[2507.1 UI Preview Video](https://www.youtube.com/watch?v=oozG1aVcg9M) - First UI Preview release video. This goes deeper than the quick start video, but is missing some features that have been added since 2507.1 was released. [2507.1 UI Preview Video](https://www.youtube.com/watch?v=oozG1aVcg9M) - First UI Preview release video. This goes deeper than the quick start video, but is missing some features that have been added since 2507.1 was released.
[2407.2 Video](https://www.youtube.com/watch?v=rqXRbgeeKSQ) - This was the main deep-dive video on FFU Builder (before it had that name). This is a good deep dive into how the BuildFFUVM.ps1 script works, but a lot has changed since that build. [2407.2 Video](https://www.youtube.com/watch?v=rqXRbgeeKSQ) - This was the main deep-dive video on FFU Builder (before it had that name). This is a good deep dive into how the BuildFFUVM.ps1 script works, but a lot has changed since that build.
# UI Progress
The UI builds are in a good spot now that most people should have moved over to using the UI if you haven't yet. I'll likely be updating main with the UI branch code within the next month or so.
+1 -1
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# M365 Apps/Office # M365 Apps/Office
![1776379074990](image/M365appsoffice/1776379074990.png) ![1760378889283](image/appsscriptvariablescopy/1760378889283.png)
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files: FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
+1 -1
View File
@@ -10,7 +10,7 @@ has_toc: false
--- ---
# Applications # Applications
![1776378755854](image/applications/1776378755854.png) ![1759881364454](image/updatescopy/1759881364454.png)
Applications can be installed in three different ways: Applications can be installed in three different ways:
+9 -2
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Apps Script Variables # Apps Script Variables
![1776379026213](image/appsscriptvariables/1776379026213.png) ![1760135511234](image/appsscriptvariables/1760135511234.png)
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. 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. 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": [], "AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false, "AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false, "AllowVHDXCaching": false,
@@ -57,6 +57,7 @@ s{
}, },
"BuildUSBDrive": false, "BuildUSBDrive": false,
"CleanupAppsISO": true, "CleanupAppsISO": true,
"CleanupCaptureISO": true,
"CleanupDeployISO": true, "CleanupDeployISO": true,
"CleanupDrivers": false, "CleanupDrivers": false,
"CompactOS": true, "CompactOS": true,
@@ -68,6 +69,7 @@ s{
"CopyPEDrivers": false, "CopyPEDrivers": false,
"CopyPPKG": false, "CopyPPKG": false,
"CopyUnattend": false, "CopyUnattend": false,
"CreateCaptureMedia": true,
"CreateDeploymentMedia": true, "CreateDeploymentMedia": true,
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}", "CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
"Disksize": 53687091200, "Disksize": 53687091200,
@@ -99,6 +101,7 @@ s{
"RemoveApps": false, "RemoveApps": false,
"RemoveFFU": false, "RemoveFFU": false,
"RemoveUpdates": false, "RemoveUpdates": false,
"ShareName": "FFUCaptureShare",
"Threads": 5, "Threads": 5,
"UpdateADK": true, "UpdateADK": true,
"UpdateEdge": true, "UpdateEdge": true,
@@ -112,7 +115,9 @@ s{
"USBDriveList": {}, "USBDriveList": {},
"UseDriversAsPEDrivers": false, "UseDriversAsPEDrivers": false,
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json", "UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
"Username": "ffu_user",
"Verbose": false, "Verbose": false,
"VMHostIPAddress": "192.168.1.169",
"VMLocation": "C:\\FFUDevelopment\\VM", "VMLocation": "C:\\FFUDevelopment\\VM",
"VMSwitchName": "External", "VMSwitchName": "External",
"WindowsArch": "x64", "WindowsArch": "x64",
@@ -128,4 +133,6 @@ 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'}` `.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
{% include page_nav.html %} {% include page_nav.html %}
+400 -495
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Bring Your Own Applications # Bring Your Own Applications
![1776378975827](image/byoapps/1776378975827.png) ![1760117497413](image/byoapps/1760117497413.png)
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. 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.
+10 -4
View File
@@ -9,7 +9,7 @@ parent: Helper Scripts
--- ---
# Create PE Media # Create PE Media
`Create-PEMedia.ps1` is a standalone helper script that creates WinPE deployment ISO files outside the main build flow. `Create-PEMedia.ps1` is a standalone helper script that creates WinPE capture or 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`. 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,19 +40,25 @@ Default output file:
Create deploy ISO for x64: Create deploy ISO for x64:
```powershell ```powershell
.\Create-PEMedia.ps1 -WindowsArch 'x64' .\Create-PEMedia.ps1 -Deploy $true -WindowsArch 'x64'
``` ```
Create deploy ISO for ARM64: Create deploy ISO for ARM64:
```powershell ```powershell
.\Create-PEMedia.ps1 -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso" .\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 deploy ISO and include PE drivers from `.\PEDrivers`: Create deploy ISO and include PE drivers from `.\PEDrivers`:
```powershell ```powershell
.\Create-PEMedia.ps1 -CopyPEDrivers $true .\Create-PEMedia.ps1 -Deploy $true -CopyPEDrivers $true
``` ```
## Stage output for USB imaging ## Stage output for USB imaging
+2 -2
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# Drivers # Drivers
![1776379108391](image/drivers/1776379108391.png) ![1760488183854](image/drivers/1760488183854.png)
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. 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 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
![1776379241359](image/drivers/1776379241359.png) ![FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo](image/drivers/1763794307504.png "FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo")
## Save Drivers.json ## Save Drivers.json
+6 -8
View File
@@ -9,19 +9,17 @@ parent: UI Overview
--- ---
# Hyper-V Settings # Hyper-V Settings
![1776378277110](image/hyperv_settings/1776378277110.png) ![1759533067934](image/ui_overview/1759533067934.png)
## Enable VM Networking (Experimental)
Controls whether the build VM is connected to a Hyper-V switch during provisioning.
Leave this off for the default offline build path. Turn it on only if you want to test internet-connected builds and understand there may be Sysprep or capture issues.
## VM Switch Name ## VM Switch Name
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. 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.
This setting is only used when **Enable VM Networking (Experimental)** is turned on. 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. ## 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.
## Disk Size (GB) ## Disk Size (GB)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+1 -1
View File
@@ -7,7 +7,7 @@ parent: UI Overview
--- ---
# Monitor # Monitor
![1776379380344](image/monitor/1776379380344.png) ![1770683109457](image/monitor/1770683109457.png)
The monitor tab parses the `.\FFUDevelopment\FFUDevelopment.log` file. This makes it easy to track what's happening during the FFU build process. The monitor tab parses the `.\FFUDevelopment\FFUDevelopment.log` file. This makes it easy to track what's happening during the FFU build process.
+8 -13
View File
@@ -27,6 +27,7 @@ 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. | | -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. | | -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. | | -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. | | -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. | | -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. | | -CleanupDrivers | bool | Cleanup Drivers | When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. |
@@ -38,25 +39,19 @@ 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. | | -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. | | -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. | | -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, 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. | | -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. | | -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}. | | -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}. |
| -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. |
| -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. |
| -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. |
| -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. |
| -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. |
| -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. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. | | -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. | | -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. | | -DriversJsonPath | string | Drivers.json Path | Path to a JSON file that specifies which drivers to download. |
| -EnableVMNetworking | bool | Enable VM Networking (Experimental) | When set to $true, connects the build VM to the selected Hyper-V switch during provisioning. Default is $false because internet-connected Sysprep is experimental. |
| -ExportConfigFile | string | Save Config File | Path to a JSON file to export the parameters used for the script. | | -ExportConfigFile | string | Save Config File | Path to a JSON file to export the parameters used for the script. |
| -FFUCaptureLocation | string | FFU Capture Location | Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU. | | -FFUCaptureLocation | string | FFU Capture Location | Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU. |
| -FFUDevelopmentPath | string | FFU Development Path | Path to the FFU development folder. Default is $PSScriptRoot. | | -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. | | -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. | | -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, 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. | | -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. |
| -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. | | -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. | | -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. | | -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. |
@@ -76,12 +71,10 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -ProductKey | string | Product Key | Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here. | | -ProductKey | string | Product Key | Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here. |
| -PromptExternalHardDiskMedia | bool | Prompt for External Hard Disk Media | When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. | | -PromptExternalHardDiskMedia | bool | Prompt for External Hard Disk Media | When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. |
| -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. | | -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. |
| -RemoveDownloadedESD | bool | Remove Downloaded ESD file(s) | When set to $true, downloaded Windows ESD files are automatically deleted after they have been applied. 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. | | -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. | | -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. | | -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. |
| -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. |
| -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. |
| -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. | | -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. | | -UpdateEdge | bool | Update Edge | When set to $true, will download and install the latest Microsoft Edge. Default is $false. |
| -UpdateLatestCU | bool | Update Latest Cumulative Update | When set to $true, will download and install the latest cumulative update. Default is $false. | | -UpdateLatestCU | bool | Update Latest Cumulative Update | When set to $true, will download and install the latest cumulative update. Default is $false. |
@@ -95,8 +88,10 @@ 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. | | -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. | | -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. | | -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. | | -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 used when -EnableVMNetworking is set to $true. | | -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. |
| -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'. | | -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'. | | -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. | | -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. |
+4 -4
View File
@@ -26,7 +26,7 @@ Follow the guide linked below to install Hyper-V on Windows client or Server
## Install PowerShell 7 ## Install PowerShell 7
PowerShell 7.6+ is required as of releases 2507+ onward. PowerShell 7 is required as of releases 2507+ onward.
[Installing PowerShell on Windows - PowerShell \| Microsoft Learn [Installing PowerShell on Windows - PowerShell \| Microsoft Learn
](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) ](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows)
@@ -47,7 +47,7 @@ Once Hyper-V has been enabled and you have rebooted, create either an external o
If you haven't [downloaded the latest release yet, do so](https://github.com/rbalsleyMSFT/FFU/releases) If you haven't [downloaded the latest release yet, do so](https://github.com/rbalsleyMSFT/FFU/releases)
Once downloaded, open the zip file and copy the content of the FFUDevelopment folder to `C:\FFUDevelopment`. You can use another location, just be sure set your FFUDevelopmentPath to the new location (e.g. `D:\FFUDevelopment`). Once downloaded, extract the zip file to `C:\FFUDevelopment`. You can use another location, just be sure set your FFUDevelopmentPath to the new location (e.g. `D:\FFUDevelopment`).
After extraction, you most likely will need to unblock the files as they'll be tagged with the mark of the web. In PowerShell run: After extraction, you most likely will need to unblock the files as they'll be tagged with the mark of the web. In PowerShell run:
@@ -57,10 +57,10 @@ Replace `C:\FFUDevelopment` with the path you extracted the files to.
## Running BuildFFUVM_UI.ps1 ## Running BuildFFUVM_UI.ps1
Either run Terminal as Admin, making sure to select PowerShell, not Windows PowerShell, or PowerShell 7.6+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1` Either run Terminal as Admin, making sure to select PowerShell, not Windows PowerShell, or PowerShell 7.5+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1`
If all went well, you should see the FFU Builder UI If all went well, you should see the FFU Builder UI
![1776376971272](image/prerequisites/1776376971272.png) ![1759527337644](image/Prerequisites/1759527337644.png)
{% include page_nav.html %} {% include page_nav.html %}
+13 -61
View File
@@ -27,12 +27,6 @@ After following this guide, you will have a USB drive with an FFU that contains
## Video Walkthrough ## Video Walkthrough
{: .note-title}
> Note
>
> The below video was recorded prior to the Fluent UI refresh. Some things will look a bit different until a new quick start video is recorded.
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;"> <div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
<iframe <iframe
src="https://www.youtube-nocookie.com/embed/kOIK5OmDugc" src="https://www.youtube-nocookie.com/embed/kOIK5OmDugc"
@@ -51,9 +45,7 @@ Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting
Click the Hyper-V Settings tab Click the Hyper-V Settings tab
You should be able to keep these settings at the defaults. **Enable VM Networking (Experimental)** is off by default and should stay off unless you specifically want to test an internet-connected VM during provisioning. 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.
If you turn on **Enable VM Networking (Experimental)**, make sure the switch you created in the prerequisites section is listed under **VM Switch Name**. If the build does not create a VM, this setting has no effect.
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. 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.
@@ -61,7 +53,7 @@ One setting you might need to set is the Logical Sector Size. 512 is the default
Click the Windows Settings tab Click the Windows Settings tab
Keep Download Windows ESD selected. FFU Builder will download the ESD file that the Windows Media Creation Tool uses. This is the recommended approach since the Media Creation Tool media ESD file is now kept up to date as of Windows 11 25H2 (it's usually updated 2-3 days after patch Tuesday). This reduces the need to service the media and saves time and disk space. If you keep ISO Path blank, FFU Builder will download the ESD file that the Windows Media Creation Tool uses. Most people should leave this blank since the Media Creation Tool media ESD file is now kept up to date as of Windows 11 25H2 (it's usually updated 2-3 days after patch Tuesday). This reduces the need to service the media and saves time and disk space.
Change the Windows language to the one of your choosing. Change the Windows language to the one of your choosing.
@@ -174,7 +166,7 @@ Check **Copy Drivers to USB drive** (even though we're doing a single model in t
Your view should look like this: Your view should look like this:
![1776377145296](image/quickstart/1776377145296.png) ![Drivers tab UI with HP 850 G8 selected and Copy Drivers to USB drive selected](image/quickstart/1769212208722.png "Drivers tab UI")
At this point, you can either Save the Drivers.json file or click Download Selected. Clicking Save Drivers.json will save the Driver model information to the Drivers.json file which will be used at build time to download the drivers. Clicking Download Selected will download the drivers right now. This can be useful for testing without having to go through an entire build, or good for airgapped environments where you can download what you need from the internet on one network, and bring that over to the airgapped network. At this point, you can either Save the Drivers.json file or click Download Selected. Clicking Save Drivers.json will save the Driver model information to the Drivers.json file which will be used at build time to download the drivers. Clicking Download Selected will download the drivers right now. This can be useful for testing without having to go through an entire build, or good for airgapped environments where you can download what you need from the internet on one network, and bring that over to the airgapped network.
@@ -192,46 +184,18 @@ Another safety measure is **Select Specific USB Drives**. When you check **Selec
**Device Naming** **Device Naming**
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: 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:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE. 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. 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** **Prompt for Device Name**
This is the default option. The unattend file is still applied, but Windows generates a random computer 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.
**Specify Device Name** **Specifying Multiple Name Prefixes**
Use this option when you want a static device name or a template such as `Comp-%serial%`. 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).
- 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.
{: .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 `<ComputerName>*</ComputerName>`, 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-title}
@@ -239,23 +203,13 @@ This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use
> >
> 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. > 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.
For the purposes of this quickstart, we'll use **Prompt for Device Name**
**Post Build Cleanup** **Post Build Cleanup**
Leave the Post Build Cleanup section at the defaults Leave the Post Build Cleanup section at the defaults
Your Build tab should look something like this: Your Build tab should look something like this:
![1776377420583](image/quickstart/1776377420583.png) ![1769218100003](image/quickstart/1769218100003.png)
![1776377442979](image/quickstart/1776377442979.png)
![1776377516879](image/quickstart/1776377516879.png)
![1776377592432](image/quickstart/1776377592432.png)
![1776377622826](image/quickstart/1776377622826.png)
Click **Build FFU** Click **Build FFU**
@@ -263,6 +217,8 @@ Depending on your internet speed, speed of your build machine, etc. this will ta
## Monitor ## Monitor
![1769218278316](image/quickstart/1769218278316.png)
The monitor tab parses the `C:\FFUDevelopment\FFUDevelopment.log` file. If you'd like to use CMTrace or another tool to monitor the log, feel free. The monitor tab has some similar functionality to CMTrace. If you click off the last line of the log in the monitor tab it will stay on that line, allowing you to read what you have selected instead of the log autoscrolling. You can also copy one or multiple lines by selecting a line and shift+clicking the last line you want to select and hitting ctrl+c to copy the lines. The monitor tab parses the `C:\FFUDevelopment\FFUDevelopment.log` file. If you'd like to use CMTrace or another tool to monitor the log, feel free. The monitor tab has some similar functionality to CMTrace. If you click off the last line of the log in the monitor tab it will stay on that line, allowing you to read what you have selected instead of the log autoscrolling. You can also copy one or multiple lines by selecting a line and shift+clicking the last line you want to select and hitting ctrl+c to copy the lines.
Now sit back, relax, and watch FFU Builder do its magic. You should see a VM pop up after everything is downloaded and Windows has been installed to the VHDX file (it may not if Hyper-V manager wasn't previously open). If you don't see a VM pop up, you can open up Hyper-V manager and look for a VM with a name that starts with **_FFU-.** Now sit back, relax, and watch FFU Builder do its magic. You should see a VM pop up after everything is downloaded and Windows has been installed to the VHDX file (it may not if Hyper-V manager wasn't previously open). If you don't see a VM pop up, you can open up Hyper-V manager and look for a VM with a name that starts with **_FFU-.**
@@ -314,22 +270,18 @@ And the Unattend folder should have an unattend.xml file with the following cont
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>*</ComputerName> <ComputerName>MyComputer</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
</unattend> </unattend>
``` ```
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. Now you're ready to deploy the FFU to your device.
## Deployment ## Deployment
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. 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.
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions). If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
+2 -2
View File
@@ -9,6 +9,6 @@ has_toc: false
--- ---
# UI Overview # UI Overview
![1776378210391](image/ui_overview/1776378210391.png) ![1759527337644](image/Prerequisites/1759527337644.png)
The user interface has 9 distinct pages for easy navigation. The user interface has 9 distinct tabs for easy navigation.
+1 -1
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# Updates # Updates
![1776378727768](image/updates/1776378727768.png) ![1759878277807](image/updates/1759878277807.png)
## Update Latest Cumulative Update ## Update Latest Cumulative Update
+5 -15
View File
@@ -9,21 +9,11 @@ parent: UI Overview
--- ---
# Windows Settings # Windows Settings
![1776378316739](image/windows_settings/1776378316739.png) ![1759535556990](image/ui_overview/1759535556990.png)
## Windows Media Source ## Windows ISO Path
### Download Windows ESD Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the latest version of Windows 10 or 11 from the Media Creation Tool.
Download Windows ESD will download the ESD file provided from the Windows Media Creation Tool
### Provide Windows ISO
You can provide your own Windows ISO (Client or Server). Good for scenarios where you want to deploy Enterprise or Education SKUs and have a product key you want to use (MAK or KMS).
#### Windows ISO Path
Path to Windows ISO file.
{: .tip-title} {: .tip-title}
@@ -31,11 +21,11 @@ Path to Windows ISO file.
> >
> Should I provide my own ISO, or let FFU Builder download the media > Should I provide my own ISO, or let FFU Builder download the media
> >
> It's recommended to use the latest ESD in most scenarios. It gets updated a couple of days after Patch Tuesday (so roughly the 2nd Thursday or Friday). See the Media Type section below for a better understanding of business and consumer media and how it impacts Subscription Activation. > It's recommended to use the latest updated ISO from Visual Studio Downloads, or the Media Creation tool. See the Media Type section below for a better understanding of business and consumer media and how it impacts Subscription Activation.
## Windows Release ## Windows Release
Can be 10, 11, different Server values, or LTSB/LTSC. Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11.
## Windows Version ## Windows Version
+4 -4
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Install Winget Applications # Install Winget Applications
![1776378791094](image/winget/1776378791094.png) ![FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app](image/applicationscopy/1759882085098.png "FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app")
## Check Winget Status ## Check Winget Status
@@ -18,19 +18,19 @@ Installing Winget applications requires that both the winget CLI and Microsoft.W
Click **Check Winget Status** to validate the versions of both the CLI and PowerShell module. If older than the minimum required version, will be updated to the latest version. Click **Check Winget Status** to validate the versions of both the CLI and PowerShell module. If older than the minimum required version, will be updated to the latest version.
![1776378813453](image/winget/1776378813453.png) ![Check Winget Status button](image/winget/1759883187930.png "Check Winget Status button")
After validating Winget status, you'll be able to search winget for applications. The larger the result set, the longer it will take for the list view to be populated. For example, if searching for **win**, the UI might appear to hang while it searches for apps with a name or id of **win** due to 669 results being returned and processed. Instead, if you search for **windows app**, 13 results are returned within a few seconds. After validating Winget status, you'll be able to search winget for applications. The larger the result set, the longer it will take for the list view to be populated. For example, if searching for **win**, the UI might appear to hang while it searches for apps with a name or id of **win** due to 669 results being returned and processed. Instead, if you search for **windows app**, 13 results are returned within a few seconds.
The UI allows for multi-selection of applications The UI allows for multi-selection of applications
![1776378860799](image/winget/1776378860799.png) ![Winget search list view with Windows App, VLC media player, and Snagit 2025 selected](image/winget/1759884200465.png "Winget search list view with Windows App, VLC media player, and Snagit 2025 selected")
You can also change the architecture, add additional exit codes, or ignore exit codes completely. You can also change the architecture, add additional exit codes, or ignore exit codes completely.
## Architecture ## Architecture
![1776378878837](image/winget/1776378878837.png) ![Architecture options that can be selected for winget applications](image/winget/1759884446099.png "Architecture options that can be selected for winget applications")
FFU Builder supports x86, x64, arm64, and x86/x64 (both) for applications in the winget source repository. For apps in the msstore source repository, the architecture cannot be changed. In most cases, x64 will be what you want, however in some cases the combo of x86 and x64 will be necessary. This might be due to runtimes (.NET, Visual C++) where an application is expecting both x86 and x64 runtimes. FFU Builder supports x86, x64, arm64, and x86/x64 (both) for applications in the winget source repository. For apps in the msstore source repository, the architecture cannot be changed. In most cases, x64 will be what you want, however in some cases the combo of x86 and x64 will be necessary. This might be due to runtimes (.NET, Visual C++) where an application is expecting both x86 and x64 runtimes.