Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65e52bb554 | |||
| c20829a72c | |||
| 7670ab886c |
@@ -1,91 +1,5 @@
|
||||
# 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
|
||||
|
||||
## 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 {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
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
|
||||
$wingetApps = @()
|
||||
$userApps = @()
|
||||
@@ -289,9 +286,9 @@ if ($wingetApps.Count -gt 0) {
|
||||
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) {
|
||||
Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
|
||||
Write-Host "Processing UserAppList.json..."
|
||||
try {
|
||||
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
if ($userContent -is [array]) {
|
||||
@@ -299,19 +296,19 @@ if (Test-Path -Path $userAppsJsonFile) {
|
||||
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
||||
}
|
||||
elseif ($userContent) {
|
||||
$userApps = @($userContent)
|
||||
$userApps = @($userContent) # Ensure it's an array
|
||||
Write-Host "Found 1 user-defined app."
|
||||
}
|
||||
else {
|
||||
Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
|
||||
Write-Error "Failed to read or parse UserAppList.json file: $_"
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -28,23 +28,6 @@ Write-Host "---------------------------------------------------" -ForegroundColo
|
||||
# Define the path to the scripts
|
||||
$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
|
||||
$scriptList = @(
|
||||
"Install-LTSCUpdate.ps1",
|
||||
@@ -68,6 +51,7 @@ foreach ($script in $scriptList) {
|
||||
switch ($script) {
|
||||
"Install-Win32Apps.ps1" {
|
||||
$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)) {
|
||||
$shouldRun = $false
|
||||
}
|
||||
@@ -85,13 +69,8 @@ foreach ($script in $scriptList) {
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
Write-Host " Running script: $script " -ForegroundColor Yellow
|
||||
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
|
||||
# Run script and wait for it to finish.
|
||||
if ($script -eq "Install-Win32Apps.ps1") {
|
||||
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
|
||||
}
|
||||
else {
|
||||
& $scriptFile
|
||||
}
|
||||
# Run script and wait for it to finish
|
||||
& $scriptFile
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ $script:uiState = [PSCustomObject]@{
|
||||
logStreamReader = $null;
|
||||
pollTimer = $null;
|
||||
currentBuildProcess = $null;
|
||||
lastConfigFilePath = $null;
|
||||
loadedDeviceNamingMode = $null
|
||||
lastConfigFilePath = $null
|
||||
};
|
||||
Flags = @{
|
||||
installAppsForcedByUpdates = $false;
|
||||
@@ -56,10 +55,7 @@ $script:uiState = [PSCustomObject]@{
|
||||
lastSortProperty = $null;
|
||||
lastSortAscending = $true;
|
||||
isBuilding = $false;
|
||||
isCleanupRunning = $false;
|
||||
isFluentSupported = $false;
|
||||
deviceNamingModeWasExplicitlyChanged = $false;
|
||||
suppressDeviceNamingChangeTracking = $false
|
||||
isCleanupRunning = $false
|
||||
};
|
||||
Defaults = @{};
|
||||
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
|
||||
@@ -124,9 +120,6 @@ $reader = New-Object System.IO.StringReader($xamlString)
|
||||
$xmlReader = [System.Xml.XmlReader]::Create($reader)
|
||||
$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({
|
||||
# Pass the state object to all initialization functions
|
||||
$script:uiState.Window = $window
|
||||
@@ -136,9 +129,6 @@ $window.Add_Loaded({
|
||||
Initialize-DynamicUIElements -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)
|
||||
try {
|
||||
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
|
||||
@@ -400,11 +390,8 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
# Not currently building: start a new build
|
||||
$btnRun.IsEnabled = $false
|
||||
|
||||
# Switch to Monitor page via navigation
|
||||
$monitorIndex = 8 # Monitor is the 9th item (index 8) in the navigation list
|
||||
if ($null -ne $script:uiState.Controls.lstNavigation) {
|
||||
$script:uiState.Controls.lstNavigation.SelectedIndex = $monitorIndex
|
||||
}
|
||||
# Switch to Monitor Tab
|
||||
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
|
||||
|
||||
# Clear previous log data and reset autoscroll
|
||||
if ($null -ne $script:uiState.Data.logData) {
|
||||
@@ -428,123 +415,6 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
return
|
||||
}
|
||||
|
||||
if ($config.EnableVMNetworking -and $config.InstallApps -and [string]::IsNullOrWhiteSpace([string]$config.VMSwitchName)) {
|
||||
[System.Windows.MessageBox]::Show("Select or enter a VM Switch Name before enabling VM networking.", "VM Switch Required", "OK", "Warning") | Out-Null
|
||||
$btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled: VM switch required for experimental networking."
|
||||
return
|
||||
}
|
||||
|
||||
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"
|
||||
# Sort top-level keys alphabetically for consistent output
|
||||
$sortedConfig = [ordered]@{}
|
||||
|
||||
@@ -3,8 +3,11 @@ param (
|
||||
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
|
||||
[string]$WindowsArch = 'x64',
|
||||
[bool]$CopyPEDrivers = $false,
|
||||
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
|
||||
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
|
||||
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
|
||||
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
|
||||
[bool]$Capture,
|
||||
[bool]$Deploy = $true
|
||||
)
|
||||
|
||||
function WriteLog($LogText) {
|
||||
@@ -74,7 +77,12 @@ function Invoke-Process {
|
||||
}
|
||||
|
||||
function New-PEMedia {
|
||||
param ()
|
||||
param (
|
||||
[Parameter()]
|
||||
[bool]$Capture,
|
||||
[Parameter()]
|
||||
[bool]$Deploy
|
||||
)
|
||||
#Need to use the Demployment and Imaging tools environment to create winPE media
|
||||
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
|
||||
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
|
||||
@@ -107,6 +115,7 @@ function New-PEMedia {
|
||||
"en-us\WinPE-Scripting_en-us.cab",
|
||||
"WinPE-PowerShell.cab",
|
||||
"en-us\WinPE-PowerShell_en-us.cab",
|
||||
"WinPE-SecureBootCmdlets.cab",
|
||||
"WinPE-StorageWMI.cab",
|
||||
"en-us\WinPE-StorageWMI_en-us.cab",
|
||||
"WinPE-DismCmdlets.cab",
|
||||
@@ -127,21 +136,36 @@ function New-PEMedia {
|
||||
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
|
||||
WriteLog "Adding package complete"
|
||||
}
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
|
||||
WriteLog 'Copy complete'
|
||||
#If $CopyPEDrivers = $true, add drivers to WinPE media using dism
|
||||
if ($CopyPEDrivers) {
|
||||
WriteLog "Adding drivers to WinPE media"
|
||||
try {
|
||||
Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
|
||||
}
|
||||
catch {
|
||||
WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
|
||||
}
|
||||
WriteLog "Adding drivers complete"
|
||||
If ($Capture) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
|
||||
WriteLog "Copy complete"
|
||||
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
|
||||
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
|
||||
# $WinPEISOName = 'WinPE_FFU_Capture.iso'
|
||||
$WinPEISOFile = $CaptureISO
|
||||
# $Capture = $false
|
||||
}
|
||||
If ($Deploy) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
|
||||
WriteLog 'Copy complete'
|
||||
#If $CopyPEDrivers = $true, add drivers to WinPE media using dism
|
||||
if ($CopyPEDrivers) {
|
||||
WriteLog "Adding drivers to WinPE media"
|
||||
try {
|
||||
Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
|
||||
}
|
||||
catch {
|
||||
WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
|
||||
}
|
||||
WriteLog "Adding drivers complete"
|
||||
}
|
||||
# $WinPEISOName = 'WinPE_FFU_Deploy.iso'
|
||||
$WinPEISOFile = $DeployISO
|
||||
|
||||
# $Deploy = $false
|
||||
}
|
||||
$WinPEISOFile = $DeployISO
|
||||
WriteLog 'Dismounting WinPE media'
|
||||
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
|
||||
WriteLog 'Dismount complete'
|
||||
@@ -156,10 +180,21 @@ function New-PEMedia {
|
||||
WriteLog "Creating WinPE ISO at $WinPEISOFile"
|
||||
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
|
||||
if($WindowsArch -eq 'x64'){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
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`""
|
||||
}
|
||||
}
|
||||
elseif($WindowsArch -eq 'arm64'){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
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`""
|
||||
}
|
||||
|
||||
}
|
||||
Invoke-Process $OSCDIMG $OSCDIMGArgs
|
||||
WriteLog "ISO created successfully"
|
||||
@@ -167,4 +202,9 @@ function New-PEMedia {
|
||||
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
|
||||
WriteLog 'Cleanup complete'
|
||||
}
|
||||
New-PEMedia
|
||||
if($Capture){
|
||||
New-PEMedia -Capture $Capture
|
||||
}
|
||||
if($Deploy){
|
||||
New-PEMedia -Deploy $Deploy
|
||||
}
|
||||
@@ -85,10 +85,12 @@ graph TD
|
||||
subgraph "VM-Based Capture (-InstallApps)"
|
||||
direction LR
|
||||
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];
|
||||
BF --> BG[Host optimizes and remounts VHDX];
|
||||
BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
|
||||
BF --> BG[VM reboots from Capture Media];
|
||||
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
|
||||
end
|
||||
|
||||
subgraph "Direct VHDX Capture"
|
||||
|
||||
@@ -6,9 +6,11 @@ function Invoke-FFUPostBuildCleanup {
|
||||
[string]$AppsPath,
|
||||
[string]$DriversPath,
|
||||
[string]$FFUCapturePath,
|
||||
[string]$CaptureISOPath,
|
||||
[string]$DeployISOPath,
|
||||
[string]$AppsISOPath,
|
||||
[string]$KBPath,
|
||||
[bool]$RemoveCaptureISO = $false,
|
||||
[bool]$RemoveDeployISO = $false,
|
||||
[bool]$RemoveAppsISO = $false,
|
||||
[bool]$RemoveDrivers = $false,
|
||||
@@ -20,9 +22,13 @@ function Invoke-FFUPostBuildCleanup {
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
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)
|
||||
if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $CaptureISOPath"
|
||||
try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $DeployISOPath"
|
||||
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
|
||||
@@ -33,6 +39,11 @@ function Invoke-FFUPostBuildCleanup {
|
||||
}
|
||||
|
||||
# Legacy / root-level WinPE ISOs (pattern-based)
|
||||
if ($RemoveCaptureISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if ($RemoveDeployISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
|
||||
@@ -161,181 +161,12 @@ function ConvertTo-SurfaceComparableName {
|
||||
|
||||
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
|
||||
$value = $value -replace '(?i)\bwith\b', ''
|
||||
$value = $value -replace '\s+', ' '
|
||||
$value = $value -replace '\s+', ' '
|
||||
|
||||
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()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -755,9 +586,6 @@ Export-ModuleMember -Function `
|
||||
Import-SurfaceDriverIndexCache, `
|
||||
Save-SurfaceDriverIndexCache, `
|
||||
ConvertTo-SurfaceComparableName, `
|
||||
ConvertTo-SurfaceHtmlText, `
|
||||
ConvertTo-SurfaceDownloadCenterLink, `
|
||||
Get-SurfaceDriverModelIndex, `
|
||||
Get-SurfaceSystemSkuReferenceIndex, `
|
||||
Get-SurfaceDownloadCenterDetails, `
|
||||
Get-SurfaceSystemSkuListForMicrosoftDriver
|
||||
@@ -161,7 +161,6 @@ function Invoke-ParallelProcessing {
|
||||
ApplicationItemData = $currentItem
|
||||
AppListJsonPath = $localJobArgs['AppListJsonPath']
|
||||
AppsPath = $localJobArgs['AppsPath']
|
||||
UserAppListPath = $localJobArgs['UserAppListPath']
|
||||
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
ProgressQueue = $localProgressQueue
|
||||
WindowsArch = $localJobArgs['WindowsArch']
|
||||
|
||||
@@ -358,8 +358,6 @@ function Start-WingetAppDownloadTask {
|
||||
[string]$AppListJsonPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter()]
|
||||
[string]$UserAppListPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -381,11 +379,11 @@ function Start-WingetAppDownloadTask {
|
||||
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||
|
||||
try {
|
||||
# Resolve the BYO app list path so duplicate checks honor custom file names.
|
||||
$userAppListPath = if (-not [string]::IsNullOrWhiteSpace($UserAppListPath)) { $UserAppListPath } else { Join-Path -Path $AppsPath -ChildPath "UserAppList.json" }
|
||||
# Define paths
|
||||
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
|
||||
$appFound = $false
|
||||
|
||||
# 1. Check the configured BYO app list and content
|
||||
# 1. Check UserAppList.json and content
|
||||
if (Test-Path -Path $userAppListPath) {
|
||||
try {
|
||||
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
|
||||
@@ -726,8 +724,6 @@ function Get-Apps {
|
||||
[string]$AppList,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter()]
|
||||
[string]$UserAppListPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -791,7 +787,6 @@ function Get-Apps {
|
||||
# CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false
|
||||
$taskArguments = @{
|
||||
AppsPath = $AppsPath
|
||||
UserAppListPath = $UserAppListPath
|
||||
AppListJsonPath = $AppList
|
||||
OrchestrationPath = $OrchestrationPath
|
||||
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 Remove-SelectedBYOApplications {
|
||||
[CmdletBinding()]
|
||||
@@ -92,10 +76,10 @@ function Remove-SelectedBYOApplications {
|
||||
}
|
||||
|
||||
# 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') {
|
||||
$userAppListPath = Get-BYOApplicationListPath -State $State
|
||||
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
|
||||
Save-BYOApplicationList -Path $userAppListPath -State $State
|
||||
}
|
||||
}
|
||||
@@ -182,7 +166,6 @@ function Add-BYOApplication {
|
||||
|
||||
# Refresh the ListView to show the changes
|
||||
$listView.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
|
||||
# Reset state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
@@ -213,7 +196,6 @@ function Add-BYOApplication {
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($application)
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
}
|
||||
|
||||
# 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.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
|
||||
$State.Controls.txtAppsScriptKey.Clear()
|
||||
$State.Controls.txtAppsScriptValue.Clear()
|
||||
# Update the header checkbox state
|
||||
@@ -314,7 +295,6 @@ function Remove-SelectedAppsScriptVariable {
|
||||
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
|
||||
}
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
|
||||
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
@@ -411,24 +391,18 @@ function Invoke-CopyBYOApps {
|
||||
)
|
||||
|
||||
$localAppsPath = $State.Controls.txtApplicationPath.Text
|
||||
$userAppListPath = Get-BYOApplicationListPath -State $State
|
||||
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
|
||||
$listView = $State.Controls.lstApplications
|
||||
|
||||
try {
|
||||
# Ensure the configured BYO app list folder exists before writing the manifest.
|
||||
$userAppListDirectory = Split-Path -Path $userAppListPath -Parent
|
||||
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.
|
||||
# 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 | 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 {
|
||||
$errorMessage = "Failed to update BYO app list at $($userAppListPath): $_"
|
||||
$errorMessage = "Failed to update UserAppList.json: $_"
|
||||
WriteLog $errorMessage
|
||||
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||
return
|
||||
|
||||
@@ -25,6 +25,7 @@ function Get-UIConfig {
|
||||
else { $null }
|
||||
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
|
||||
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
|
||||
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
|
||||
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
|
||||
CompactOS = $State.Controls.chkCompactOS.IsChecked
|
||||
@@ -36,23 +37,15 @@ function Get-UIConfig {
|
||||
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
|
||||
CopyPPKG = $State.Controls.chkCopyPPKG.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
|
||||
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
|
||||
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
|
||||
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
|
||||
UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
|
||||
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
|
||||
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
|
||||
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
|
||||
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
|
||||
DriversFolder = $State.Controls.txtDriversFolder.Text
|
||||
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
|
||||
EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
|
||||
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
|
||||
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
|
||||
FFUPrefix = $State.Controls.txtVMNamePrefix.Text
|
||||
@@ -61,7 +54,6 @@ function Get-UIConfig {
|
||||
InstallOffice = $State.Controls.chkInstallOffice.IsChecked
|
||||
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
|
||||
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
|
||||
# Make = $null
|
||||
MediaType = $State.Controls.cmbMediaType.SelectedItem
|
||||
@@ -91,6 +83,7 @@ function Get-UIConfig {
|
||||
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
|
||||
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
|
||||
RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
|
||||
ShareName = $State.Controls.txtShareName.Text
|
||||
UpdateADK = $State.Controls.chkUpdateADK.IsChecked
|
||||
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
|
||||
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
|
||||
@@ -100,13 +93,14 @@ function Get-UIConfig {
|
||||
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
|
||||
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
|
||||
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
|
||||
UserAppListPath = $State.Controls.txtUserAppListPath.Text
|
||||
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json"
|
||||
USBDriveList = @{}
|
||||
Username = $State.Controls.txtUsername.Text
|
||||
Threads = [int]$State.Controls.txtThreads.Text
|
||||
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
|
||||
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
|
||||
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
|
||||
Verbose = $State.Controls.chkVerbose.IsChecked
|
||||
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
|
||||
VMLocation = $State.Controls.txtVMLocation.Text
|
||||
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$State.Controls.txtCustomVMSwitchName.Text
|
||||
@@ -418,6 +412,7 @@ function Select-VMSwitchFromConfig {
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
|
||||
$State.Data.customVMSwitchName = $configSwitch
|
||||
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
|
||||
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
|
||||
}
|
||||
}
|
||||
@@ -432,19 +427,12 @@ function Update-UIFromConfig {
|
||||
|
||||
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
|
||||
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
|
||||
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
|
||||
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
|
||||
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
|
||||
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
|
||||
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
|
||||
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State
|
||||
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
|
||||
@@ -456,58 +444,19 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
|
||||
Set-UIValue -ControlName 'txtUnattendX64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendX64FilePath' -State $State
|
||||
Set-UIValue -ControlName 'txtUnattendArm64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendArm64FilePath' -State $State
|
||||
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendX64FilePath.Text)) {
|
||||
$State.Controls.txtUnattendX64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendArm64FilePath.Text)) {
|
||||
$State.Controls.txtUnattendArm64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
|
||||
}
|
||||
|
||||
# USB Drive Modification group (Build Tab)
|
||||
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
|
||||
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
|
||||
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)
|
||||
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
|
||||
Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
|
||||
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
|
||||
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
|
||||
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
|
||||
@@ -516,30 +465,17 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
|
||||
|
||||
# Hyper-V Settings
|
||||
Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
|
||||
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
|
||||
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
|
||||
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
|
||||
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
|
||||
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
|
||||
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
|
||||
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
|
||||
if (-not ($true -eq $State.Controls.chkEnableVMNetworking.IsChecked)) {
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
}
|
||||
|
||||
# Windows Settings
|
||||
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
|
||||
# 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)
|
||||
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 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -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
|
||||
$appsScriptVarsKeyExists = $false
|
||||
@@ -713,7 +648,6 @@ function Update-UIFromConfig {
|
||||
}
|
||||
# Update the ListView's ItemsSource after populating the data list
|
||||
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
|
||||
@@ -782,7 +716,6 @@ function Update-UIFromConfig {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstUSBDrives.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
|
||||
|
||||
# Update the Select All header checkbox state
|
||||
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
|
||||
@@ -843,7 +776,6 @@ function Update-UIFromConfig {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstAdditionalFFUs.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
|
||||
@@ -903,7 +835,7 @@ function Invoke-RestoreDefaults {
|
||||
$rootPath = $State.FFUDevelopmentPath
|
||||
|
||||
# Normalize potential array values to single strings
|
||||
function Get-PathScalar {
|
||||
function Normalize-PathScalar {
|
||||
param([object]$value)
|
||||
if ($null -eq $value) { return $null }
|
||||
if ($value -is [System.Array]) {
|
||||
@@ -918,20 +850,21 @@ function Invoke-RestoreDefaults {
|
||||
}
|
||||
|
||||
$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)) {
|
||||
$driversPath = Join-Path $rootPath 'Drivers'
|
||||
}
|
||||
else {
|
||||
$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 }
|
||||
|
||||
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
|
||||
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
|
||||
$appsISOPath = Join-Path $rootPath 'Apps.iso'
|
||||
|
||||
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (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")
|
||||
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
|
||||
WriteLog "RestoreDefaults: User cancelled."
|
||||
@@ -967,9 +900,11 @@ function Invoke-RestoreDefaults {
|
||||
-AppsPath $appsPath `
|
||||
-DriversPath $driversPath `
|
||||
-FFUCapturePath $ffuCapturePath `
|
||||
-CaptureISOPath $captureISOPath `
|
||||
-DeployISOPath $deployISOPath `
|
||||
-AppsISOPath $appsISOPath `
|
||||
-KBPath (Join-Path $rootPath 'KB') `
|
||||
-RemoveCaptureISO:$true `
|
||||
-RemoveDeployISO:$true `
|
||||
-RemoveAppsISO:$true `
|
||||
-RemoveDrivers:$true `
|
||||
@@ -1104,7 +1039,6 @@ function Import-ConfigSupplementalAssets {
|
||||
})
|
||||
}
|
||||
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
|
||||
$loadedWinget = $true
|
||||
if ($null -ne $State.Controls.wingetSearchPanel) {
|
||||
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
|
||||
@@ -1239,7 +1173,6 @@ function Import-ConfigSupplementalAssets {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
|
||||
$headerChk = $State.Controls.chkSelectAllDriverModels
|
||||
if ($null -ne $headerChk) {
|
||||
|
||||
@@ -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.
|
||||
# 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.
|
||||
# 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
|
||||
$Headers["Cookie"] = $lenovoCookie
|
||||
|
||||
@@ -15,8 +15,100 @@ function Get-MicrosoftDriversModelList {
|
||||
[string]$DriversFolder
|
||||
)
|
||||
|
||||
# Keep the UI signature unchanged while using the shared Learn-based source.
|
||||
return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
|
||||
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
|
||||
$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 Save-MicrosoftDriversTask {
|
||||
|
||||
@@ -373,7 +373,6 @@ function Search-DriverModels {
|
||||
}
|
||||
|
||||
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
$filteredCount = 0
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($item in $collectionView) { $filteredCount++ }
|
||||
@@ -716,7 +715,6 @@ function Import-DriversJson {
|
||||
|
||||
# Update the UI and apply any existing filter
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
|
||||
|
||||
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
|
||||
@@ -788,7 +786,6 @@ function Invoke-GetModels {
|
||||
|
||||
# Update the UI ItemsSource to point to the new list and clear the filter
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
$State.Controls.txtModelFilter.Text = ""
|
||||
|
||||
if ($State.Data.allDriverModels.Count -gt 0) {
|
||||
|
||||
@@ -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.
|
||||
#>
|
||||
|
||||
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 {
|
||||
param([PSCustomObject]$State)
|
||||
WriteLog "Registering UI event handlers..."
|
||||
@@ -359,7 +24,7 @@ function Register-EventHandlers {
|
||||
|
||||
# Define a handler to validate pasted text, ensuring it's only integers
|
||||
$integerPastingHandler = {
|
||||
param($eventSource, $pastingEventArgs)
|
||||
param($sender, $pastingEventArgs)
|
||||
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
|
||||
$pastedText = $pastingEventArgs.DataObject.GetData([string])
|
||||
# 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
|
||||
$State.Controls.btnBrowseFFUDevPath.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
@@ -555,34 +94,7 @@ function Register-EventHandlers {
|
||||
$localState = $window.Tag
|
||||
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
|
||||
if ($selectedPath) {
|
||||
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
|
||||
$currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
|
||||
$currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
|
||||
$currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
|
||||
$previousDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
|
||||
$previousDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
|
||||
$previousDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
|
||||
$previousDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
|
||||
$localState.Controls.txtFFUDevPath.Text = $selectedPath
|
||||
$newDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $selectedPath
|
||||
$newDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $selectedPath
|
||||
$newDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'x64'
|
||||
$newDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'arm64'
|
||||
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath) -or $currentPrefixesPath -ieq $previousDefaultPrefixesPath) {
|
||||
$localState.Controls.txtDeviceNamePrefixesPath.Text = $newDefaultPrefixesPath
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath) -or $currentSerialComputerNamesPath -ieq $previousDefaultSerialComputerNamesPath) {
|
||||
$localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $newDefaultSerialComputerNamesPath
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath) -or $currentUnattendX64FilePath -ieq $previousDefaultUnattendX64FilePath) {
|
||||
$localState.Controls.txtUnattendX64FilePath.Text = $newDefaultUnattendX64FilePath
|
||||
}
|
||||
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
|
||||
# The USB Expander is always visible; the checkbox controls child settings only
|
||||
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$localState.Controls.usbSection.Visibility = 'Visible'
|
||||
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true
|
||||
})
|
||||
$State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$localState.Controls.usbSection.Visibility = 'Collapsed'
|
||||
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false
|
||||
$localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false
|
||||
$localState.Controls.lstUSBDrives.Items.Clear()
|
||||
@@ -935,7 +220,6 @@ function Register-EventHandlers {
|
||||
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
|
||||
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
|
||||
}
|
||||
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
|
||||
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
|
||||
$localState.Controls.lstUSBDrives.SelectedIndex = 0
|
||||
}
|
||||
@@ -969,30 +253,42 @@ function Register-EventHandlers {
|
||||
})
|
||||
|
||||
# Hyper-V tab event handlers
|
||||
$State.Controls.chkEnableVMNetworking.Add_Checked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Update-VMNetworkingControls -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.chkEnableVMNetworking.Add_Unchecked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Update-VMNetworkingControls -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.cmbVMSwitchName.Add_SelectionChanged({
|
||||
param($eventSource, $selectionChangedEventArgs)
|
||||
# The state object is available via the parent window's Tag property
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
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({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -1003,31 +299,12 @@ function Register-EventHandlers {
|
||||
})
|
||||
|
||||
# Windows Settings tab Event Handlers
|
||||
# Windows Media Source radio buttons
|
||||
if ($null -ne $State.Controls.rbProvideISO) {
|
||||
$State.Controls.rbProvideISO.Add_Checked({
|
||||
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 = 'Visible'
|
||||
# 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.txtISOPath.Add_TextChanged({
|
||||
param($eventSource, $textChangedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.cmbWindowsRelease.Add_SelectionChanged({
|
||||
param($eventSource, $selectionChangedEventArgs)
|
||||
@@ -1037,13 +314,7 @@ function Register-EventHandlers {
|
||||
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
|
||||
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
}
|
||||
# Determine ISO path based on radio button state
|
||||
$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-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||
Update-WindowsSkuCombo -State $localState
|
||||
Update-WindowsArchCombo -State $localState
|
||||
|
||||
@@ -1164,18 +435,10 @@ function Register-EventHandlers {
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$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 }
|
||||
})
|
||||
|
||||
$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({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -1203,23 +466,17 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
# Default the save dialog to the configured BYO app list path.
|
||||
$currentPath = $localState.Controls.txtUserAppListPath.Text
|
||||
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
|
||||
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||
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' `
|
||||
-Title "Save BYO App List" `
|
||||
-Title "Save Application List" `
|
||||
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||
-InitialDirectory $initialDir `
|
||||
-FileName $fileName `
|
||||
-FileName "UserAppList.json" `
|
||||
-DefaultExt ".json"
|
||||
|
||||
if ($savePath) {
|
||||
$localState.Controls.txtUserAppListPath.Text = $savePath
|
||||
Save-BYOApplicationList -Path $savePath -State $localState
|
||||
}
|
||||
if ($savePath) { Save-BYOApplicationList -Path $savePath -State $localState }
|
||||
})
|
||||
|
||||
$State.Controls.btnLoadBYOApplications.Add_Click({
|
||||
@@ -1227,18 +484,15 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
# Default the import dialog to the configured BYO app list path.
|
||||
$currentPath = $localState.Controls.txtUserAppListPath.Text
|
||||
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
|
||||
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
|
||||
|
||||
$loadPath = Invoke-BrowseAction -Type 'OpenFile' `
|
||||
-Title "Import BYO App List" `
|
||||
-Title "Import Application List" `
|
||||
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||
-InitialDirectory $initialDir
|
||||
|
||||
if ($loadPath) {
|
||||
$localState.Controls.txtUserAppListPath.Text = $loadPath
|
||||
Import-BYOApplicationList -Path $loadPath -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.
|
||||
#>
|
||||
|
||||
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 {
|
||||
param([PSCustomObject]$State)
|
||||
WriteLog "Initializing UI control references..."
|
||||
@@ -112,9 +21,6 @@ function Initialize-UIControls {
|
||||
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
|
||||
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
|
||||
$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.cmbWindowsArch = $window.FindName('cmbWindowsArch')
|
||||
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
|
||||
@@ -164,10 +70,8 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
|
||||
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
|
||||
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
|
||||
$State.Controls.userAppListPathPanel = $window.FindName('userAppListPathPanel')
|
||||
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
|
||||
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
|
||||
$State.Controls.btnBrowseUserAppListPath = $window.FindName('btnBrowseUserAppListPath')
|
||||
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
|
||||
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
|
||||
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
|
||||
@@ -205,47 +109,29 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtStatus = $window.FindName('txtStatus')
|
||||
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
|
||||
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
|
||||
$State.Controls.chkEnableVMNetworking = $window.FindName('chkEnableVMNetworking')
|
||||
$State.Controls.spVMNetworkingSettings = $window.FindName('spVMNetworkingSettings')
|
||||
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
|
||||
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
|
||||
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
|
||||
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
|
||||
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
|
||||
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation')
|
||||
$State.Controls.txtShareName = $window.FindName('txtShareName')
|
||||
$State.Controls.txtUsername = $window.FindName('txtUsername')
|
||||
$State.Controls.txtThreads = $window.FindName('txtThreads')
|
||||
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
|
||||
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
|
||||
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
|
||||
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
|
||||
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
|
||||
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
|
||||
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
|
||||
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
|
||||
$State.Controls.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.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
|
||||
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
|
||||
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
|
||||
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
|
||||
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
|
||||
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
|
||||
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
|
||||
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
|
||||
@@ -273,7 +159,6 @@ function Initialize-UIControls {
|
||||
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
|
||||
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
|
||||
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
|
||||
$State.Controls.txtUserAppListPath = $window.FindName('txtUserAppListPath')
|
||||
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
|
||||
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
|
||||
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
|
||||
@@ -295,59 +180,11 @@ function Initialize-UIControls {
|
||||
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
|
||||
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
|
||||
|
||||
# Home page
|
||||
$State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue')
|
||||
$State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue')
|
||||
$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')
|
||||
# Monitor Tab
|
||||
$State.Controls.MainTabControl = $window.FindName('MainTabControl')
|
||||
$State.Controls.MonitorTab = $window.FindName('MonitorTab')
|
||||
$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
|
||||
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
|
||||
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
|
||||
@@ -368,11 +205,19 @@ function Initialize-VMSwitchData {
|
||||
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
|
||||
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
|
||||
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
|
||||
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
|
||||
if ($null -ne $firstSwitch -and $State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
|
||||
}
|
||||
else {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found or key null
|
||||
}
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
}
|
||||
else {
|
||||
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +233,8 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
|
||||
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
|
||||
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
|
||||
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
|
||||
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
|
||||
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
|
||||
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
|
||||
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
|
||||
@@ -397,8 +244,7 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
|
||||
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
|
||||
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
|
||||
$State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
|
||||
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
|
||||
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
|
||||
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
|
||||
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
|
||||
@@ -406,22 +252,8 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
|
||||
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
|
||||
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
|
||||
$defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', '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.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
|
||||
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
|
||||
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
|
||||
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
|
||||
@@ -429,6 +261,7 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
|
||||
$State.Controls.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD
|
||||
$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.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
|
||||
@@ -437,9 +270,7 @@ function Initialize-UIDefaults {
|
||||
Update-BitsPrioritySetting -State $State
|
||||
|
||||
# Hyper-V Settings defaults from General Defaults
|
||||
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
|
||||
Initialize-VMSwitchData -State $State
|
||||
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
|
||||
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
|
||||
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
|
||||
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
|
||||
@@ -448,12 +279,7 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
|
||||
|
||||
# Populate Windows Release, Version, and SKU comboboxes
|
||||
# Initialize Windows settings combos based on media source mode
|
||||
$initIsoPath = $State.Defaults.windowsSettingsDefaults.DefaultISOPath
|
||||
if ($null -ne $State.Controls.rbProvideISO -and -not $State.Controls.rbProvideISO.IsChecked) {
|
||||
$initIsoPath = ''
|
||||
}
|
||||
Get-WindowsSettingsCombos -isoPath $initIsoPath -State $State
|
||||
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State
|
||||
|
||||
# Windows Settings tab defaults
|
||||
$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.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
|
||||
$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.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
|
||||
|
||||
@@ -520,73 +345,23 @@ function Initialize-UIDefaults {
|
||||
# Set initial state for InstallApps checkbox based on updates
|
||||
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
|
||||
Update-OfficePanelVisibility -State $State
|
||||
|
||||
# Set initial state for Application panel visibility
|
||||
Update-ApplicationPanelVisibility -State $State
|
||||
|
||||
# Set initial state for BYO Apps copy button
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
# Set initial state for BYO Apps copy button
|
||||
Update-CopyButtonState -State $State
|
||||
}
|
||||
|
||||
function Initialize-DynamicUIElements {
|
||||
param([PSCustomObject]$State)
|
||||
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
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$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)))
|
||||
$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
|
||||
$wingetGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$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)))
|
||||
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
|
||||
|
||||
@@ -673,11 +444,9 @@ function Initialize-DynamicUIElements {
|
||||
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$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.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.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
|
||||
$byoAppsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstApplications.View = $byoAppsGridView
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally
|
||||
$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)))
|
||||
$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 "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
|
||||
|
||||
# Keep BYO application columns sized to the current visible content.
|
||||
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
|
||||
|
||||
# Apps Script Variables ListView setup
|
||||
# Bind ItemsSource to the data list
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$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)))
|
||||
$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 {
|
||||
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
|
||||
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
|
||||
$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)))
|
||||
$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 {
|
||||
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
|
||||
$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)))
|
||||
$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 {
|
||||
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()
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
}
|
||||
|
||||
# Function to move selected item to the top
|
||||
@@ -134,7 +133,6 @@ function Update-ListViewItemStatus {
|
||||
if ($null -ne $itemToUpdate) {
|
||||
$itemToUpdate.$StatusProperty = $StatusValue
|
||||
$ListView.Items.Refresh() # Refresh the view to show the change
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
}
|
||||
else {
|
||||
# Log if item not found (for debugging)
|
||||
@@ -345,7 +343,6 @@ function Add-SelectableGridViewColumn {
|
||||
# Create the "Select All" CheckBox for the header
|
||||
$headerCheckBox = New-Object System.Windows.Controls.CheckBox
|
||||
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
|
||||
$headerCheckBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
|
||||
|
||||
# Store header metadata, including whether select-all should only affect visible rows.
|
||||
$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
|
||||
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
|
||||
|
||||
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$selectableColumn.Header = $selectableHeader
|
||||
$selectableColumn.Header = $headerCheckBox
|
||||
$selectableColumn.Width = $ColumnWidth
|
||||
|
||||
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||
@@ -496,209 +471,6 @@ function Add-SelectableGridViewColumn {
|
||||
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
|
||||
}
|
||||
|
||||
# Function to request a deferred GridView column auto-size pass
|
||||
function Request-ListViewColumnAutoResize {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
# Skip startup calls until the visual tree has finished loading.
|
||||
if (-not $ListView.IsLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
# Ensure the ListView has registered auto-resize metadata before scheduling work.
|
||||
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
|
||||
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
|
||||
if ($autoResizeState.ResizePending) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState.ResizePending = $true
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
|
||||
try {
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$gridView = [System.Windows.Controls.GridView]$ListView.View
|
||||
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
|
||||
$visibleItems = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
if ($null -ne $ListView.ItemsSource) {
|
||||
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($visibleItem in $collectionView) {
|
||||
$visibleItems.Add($visibleItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($visibleItem in $ListView.Items) {
|
||||
$visibleItems.Add($visibleItem)
|
||||
}
|
||||
}
|
||||
|
||||
$ListView.UpdateLayout()
|
||||
|
||||
$columnIndex = 0
|
||||
foreach ($column in $gridView.Columns) {
|
||||
if ($fixedColumnIndexes -contains $columnIndex) {
|
||||
$columnIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
if ($null -eq $column) {
|
||||
$columnIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
$headerText = ""
|
||||
$propertyName = $null
|
||||
|
||||
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
|
||||
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
|
||||
}
|
||||
|
||||
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
|
||||
$headerText = [string]$column.Header.Content
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
|
||||
$propertyName = [string]$column.Header.Tag
|
||||
}
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
|
||||
$headerText = [string]$column.Header
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($headerText)) {
|
||||
$headerText = $propertyName
|
||||
}
|
||||
|
||||
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
|
||||
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
|
||||
$headerMeasureBlock.FontFamily = $ListView.FontFamily
|
||||
$headerMeasureBlock.FontSize = $ListView.FontSize
|
||||
$headerMeasureBlock.FontStyle = $ListView.FontStyle
|
||||
$headerMeasureBlock.FontWeight = $ListView.FontWeight
|
||||
$headerMeasureBlock.FontStretch = $ListView.FontStretch
|
||||
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
|
||||
|
||||
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
|
||||
|
||||
foreach ($item in $visibleItems) {
|
||||
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$itemProperty = $null
|
||||
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
|
||||
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
|
||||
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
|
||||
$itemProperty = $matchedProperties | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $itemProperty) {
|
||||
continue
|
||||
}
|
||||
|
||||
$itemText = [string]$itemProperty.Value
|
||||
$extraWidth = 28
|
||||
|
||||
switch ($propertyName) {
|
||||
'Architecture' {
|
||||
$extraWidth = 52
|
||||
}
|
||||
'AdditionalExitCodes' {
|
||||
$extraWidth = 44
|
||||
}
|
||||
'IgnoreNonZeroExitCodes' {
|
||||
$itemText = ' '
|
||||
$extraWidth = 48
|
||||
}
|
||||
'IgnoreExitCodes' {
|
||||
$extraWidth = 28
|
||||
}
|
||||
}
|
||||
|
||||
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
|
||||
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
|
||||
$itemMeasureBlock.FontFamily = $ListView.FontFamily
|
||||
$itemMeasureBlock.FontSize = $ListView.FontSize
|
||||
$itemMeasureBlock.FontStyle = $ListView.FontStyle
|
||||
$itemMeasureBlock.FontWeight = $ListView.FontWeight
|
||||
$itemMeasureBlock.FontStretch = $ListView.FontStretch
|
||||
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
|
||||
|
||||
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
|
||||
if ($itemWidth -gt $calculatedWidth) {
|
||||
$calculatedWidth = $itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
|
||||
$calculatedWidth = [math]::Max($calculatedWidth, 120)
|
||||
}
|
||||
|
||||
$column.Width = [math]::Max($calculatedWidth, 40)
|
||||
$columnIndex++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
|
||||
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
|
||||
WriteLog $_.InvocationInfo.PositionMessage
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
|
||||
WriteLog $_.ScriptStackTrace
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
$autoResizeState.ResizePending = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Function to enable reusable auto-resizing for GridView-backed ListViews
|
||||
function Enable-ListViewColumnAutoResize {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView,
|
||||
|
||||
[int[]]$FixedColumnIndexes = @()
|
||||
)
|
||||
|
||||
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
|
||||
|
||||
# Only GridView-backed lists can participate in column auto-sizing.
|
||||
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
|
||||
return
|
||||
}
|
||||
|
||||
if ($ListView.Resources.Contains($autoResizeStateKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState = [PSCustomObject]@{
|
||||
FixedColumnIndexes = @($FixedColumnIndexes)
|
||||
ResizePending = $false
|
||||
}
|
||||
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
|
||||
}
|
||||
|
||||
# Function to update the IsChecked state of a "Select All" header CheckBox
|
||||
function Update-SelectAllHeaderCheckBoxState {
|
||||
param(
|
||||
@@ -778,7 +550,6 @@ function Invoke-ListViewItemToggle {
|
||||
# Toggle the IsSelected property
|
||||
$selectedItem.IsSelected = -not $selectedItem.IsSelected
|
||||
$ListView.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
|
||||
# Update the 'Select All' header checkbox state
|
||||
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
|
||||
@@ -949,8 +720,6 @@ function Invoke-ListViewSort {
|
||||
$newView.Filter = $existingFilter
|
||||
}
|
||||
}
|
||||
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -1173,9 +942,6 @@ function Invoke-BrowseAction {
|
||||
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||
$dialog.InitialDirectory = $InitialDirectory
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
|
||||
$dialog.FileName = $FileName
|
||||
}
|
||||
if ($dialog.ShowDialog()) {
|
||||
return $dialog.FileName
|
||||
}
|
||||
@@ -1254,7 +1020,6 @@ function Clear-ListViewContent {
|
||||
}
|
||||
|
||||
$ListViewControl.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $ListViewControl
|
||||
|
||||
# Clear any specified textboxes
|
||||
if ($null -ne $TextBoxesToClear) {
|
||||
|
||||
@@ -59,7 +59,6 @@ function Search-WingetApps {
|
||||
|
||||
# Update the ListView's ItemsSource using the passed-in State object
|
||||
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
|
||||
|
||||
# Update status text
|
||||
$statusText = ""
|
||||
@@ -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.Filter = "JSON files (*.json)|*.json"
|
||||
$sfd.Title = "Save Winget App List"
|
||||
$sfd.InitialDirectory = $initialDirectory
|
||||
$sfd.FileName = $fileName
|
||||
$sfd.Title = "Save App List"
|
||||
# Correctly get the path from the UI control via the State object
|
||||
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text
|
||||
$sfd.FileName = "AppList.json"
|
||||
|
||||
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
|
||||
$State.Controls.txtAppListJsonPath.Text = $sfd.FileName
|
||||
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
|
||||
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
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.Filter = "JSON files (*.json)|*.json"
|
||||
$ofd.Title = "Import Winget App List"
|
||||
$ofd.InitialDirectory = $initialDirectory
|
||||
$ofd.Title = "Import App List"
|
||||
# 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) {
|
||||
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
|
||||
@@ -159,16 +144,16 @@ function Import-WingetList {
|
||||
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
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
|
||||
|
||||
foreach ($appInfo in $importedAppsData.apps) {
|
||||
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
|
||||
$newAppListForItemsSource.Add([PSCustomObject]@{
|
||||
IsSelected = $true
|
||||
IsSelected = $true # Imported apps are marked as selected
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = ""
|
||||
Version = "" # Will be populated when searching or if data exists
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
|
||||
@@ -179,14 +164,12 @@ function Import-WingetList {
|
||||
}
|
||||
|
||||
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
|
||||
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
|
||||
|
||||
[System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
|
||||
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error")
|
||||
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,20 +106,16 @@ function Get-GeneralDefaults {
|
||||
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
|
||||
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
|
||||
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
|
||||
$unattendPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "unattend"
|
||||
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
|
||||
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
|
||||
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
|
||||
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
|
||||
$deviceNamePrefixesPath = Join-Path -Path $unattendPath -ChildPath "prefixes.txt"
|
||||
$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]@{
|
||||
# Build Tab Defaults
|
||||
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
|
||||
FFUCaptureLocation = $ffuCapturePath
|
||||
ShareName = "FFUCaptureShare"
|
||||
Username = "ffu_user"
|
||||
Threads = 5
|
||||
BitsPriority = 'Normal'
|
||||
MaxUSBDrives = 5
|
||||
@@ -127,6 +123,7 @@ function Get-GeneralDefaults {
|
||||
CompactOS = $true
|
||||
Optimize = $true
|
||||
AllowVHDXCaching = $false
|
||||
CreateCaptureMedia = $true
|
||||
CreateDeploymentMedia = $true
|
||||
Verbose = $false
|
||||
AllowExternalHardDiskMedia = $false
|
||||
@@ -137,15 +134,8 @@ function Get-GeneralDefaults {
|
||||
CopyUnattend = $false
|
||||
CopyPPKG = $false
|
||||
InjectUnattend = $false
|
||||
UnattendX64FilePath = $unattendX64FilePath
|
||||
UnattendArm64FilePath = $unattendArm64FilePath
|
||||
DeviceNamingMode = 'None'
|
||||
DeviceNameTemplate = ''
|
||||
DeviceNamePrefixesPath = $deviceNamePrefixesPath
|
||||
DeviceNamePrefixes = @()
|
||||
DeviceNameSerialComputerNamesPath = $deviceNameSerialComputerNamesPath
|
||||
DeviceNameSerialComputerNames = @()
|
||||
CleanupAppsISO = $true
|
||||
CleanupCaptureISO = $true
|
||||
CleanupDeployISO = $true
|
||||
CleanupDrivers = $false
|
||||
RemoveFFU = $false
|
||||
@@ -153,7 +143,7 @@ function Get-GeneralDefaults {
|
||||
RemoveUpdates = $false
|
||||
RemoveDownloadedESD = $true
|
||||
# Hyper-V Settings Defaults
|
||||
EnableVMNetworking = $false
|
||||
VMHostIPAddress = ""
|
||||
DiskSizeGB = 50
|
||||
MemoryGB = 4
|
||||
Processors = 4
|
||||
@@ -173,7 +163,6 @@ function Get-GeneralDefaults {
|
||||
InstallApps = $false
|
||||
ApplicationPath = $appsPath
|
||||
AppListJsonPath = $appListJsonPath
|
||||
UserAppListPath = $userAppListPath
|
||||
InstallWingetApps = $false
|
||||
BringYourOwnApps = $false
|
||||
# M365 Apps/Office Tab Defaults
|
||||
@@ -277,7 +266,6 @@ function Update-AdditionalFFUList {
|
||||
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
|
||||
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
|
||||
}
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
@@ -317,7 +305,6 @@ function Update-ApplicationPanelVisibility {
|
||||
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
|
||||
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
|
||||
$State.Controls.userAppListPathPanel.Visibility = $subOptionVisibility
|
||||
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
|
||||
$State.Controls.chkBringYourOwnApps.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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
SerialNumber,ComputerName
|
||||
ABC12345,CORP-001
|
||||
DEF67890,KIOSK-010
|
||||
XYZ24680,STORE-015
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<settings pass="specialize">
|
||||
<!--<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">
|
||||
<ComputerName>*</ComputerName><!--Leave Default will be renamed-->
|
||||
<ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed-->
|
||||
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
|
||||
</component>
|
||||
<!-- Place additional Components Elements and Settings below here: -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<settings pass="specialize">
|
||||
<!--<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">
|
||||
<ComputerName>*</ComputerName>
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<settings pass="specialize">
|
||||
<!--<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">
|
||||
<ComputerName>*</ComputerName>
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
# Updates
|
||||
|
||||
## 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.
|
||||
**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)
|
||||
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
[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.
|
||||
|
||||
@@ -9,7 +9,7 @@ parent: UI Overview
|
||||
---
|
||||
# M365 Apps/Office
|
||||
|
||||

|
||||

|
||||
|
||||
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ has_toc: false
|
||||
---
|
||||
# Applications
|
||||
|
||||

|
||||

|
||||
|
||||
Applications can be installed in three different ways:
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ grand_parent: UI Overview
|
||||
---
|
||||
# Apps Script Variables
|
||||
|
||||

|
||||

|
||||
|
||||
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
|
||||
|
||||
@@ -45,7 +45,7 @@ In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `App
|
||||
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
|
||||
|
||||
```
|
||||
s{
|
||||
{
|
||||
"AdditionalFFUFiles": [],
|
||||
"AllowExternalHardDiskMedia": false,
|
||||
"AllowVHDXCaching": false,
|
||||
@@ -57,6 +57,7 @@ s{
|
||||
},
|
||||
"BuildUSBDrive": false,
|
||||
"CleanupAppsISO": true,
|
||||
"CleanupCaptureISO": true,
|
||||
"CleanupDeployISO": true,
|
||||
"CleanupDrivers": false,
|
||||
"CompactOS": true,
|
||||
@@ -68,6 +69,7 @@ s{
|
||||
"CopyPEDrivers": false,
|
||||
"CopyPPKG": false,
|
||||
"CopyUnattend": false,
|
||||
"CreateCaptureMedia": true,
|
||||
"CreateDeploymentMedia": true,
|
||||
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
|
||||
"Disksize": 53687091200,
|
||||
@@ -99,6 +101,7 @@ s{
|
||||
"RemoveApps": false,
|
||||
"RemoveFFU": false,
|
||||
"RemoveUpdates": false,
|
||||
"ShareName": "FFUCaptureShare",
|
||||
"Threads": 5,
|
||||
"UpdateADK": true,
|
||||
"UpdateEdge": true,
|
||||
@@ -112,7 +115,9 @@ s{
|
||||
"USBDriveList": {},
|
||||
"UseDriversAsPEDrivers": false,
|
||||
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
|
||||
"Username": "ffu_user",
|
||||
"Verbose": false,
|
||||
"VMHostIPAddress": "192.168.1.169",
|
||||
"VMLocation": "C:\\FFUDevelopment\\VM",
|
||||
"VMSwitchName": "External",
|
||||
"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'}`
|
||||
|
||||
|
||||
|
||||
{% include page_nav.html %}
|
||||
|
||||
@@ -10,7 +10,7 @@ grand_parent: UI Overview
|
||||
---
|
||||
# Bring Your Own Applications
|
||||
|
||||

|
||||

|
||||
|
||||
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ parent: Helper Scripts
|
||||
---
|
||||
# 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`.
|
||||
|
||||
@@ -40,19 +40,25 @@ Default output file:
|
||||
Create deploy ISO for x64:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1 -WindowsArch 'x64'
|
||||
.\Create-PEMedia.ps1 -Deploy $true -WindowsArch 'x64'
|
||||
```
|
||||
|
||||
Create deploy ISO for ARM64:
|
||||
|
||||
```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`:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1 -CopyPEDrivers $true
|
||||
.\Create-PEMedia.ps1 -Deploy $true -CopyPEDrivers $true
|
||||
```
|
||||
|
||||
## Stage output for USB imaging
|
||||
|
||||
@@ -9,7 +9,7 @@ parent: UI Overview
|
||||
---
|
||||
# Drivers
|
||||
|
||||

|
||||

|
||||
|
||||
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
|
||||
|
||||
@@ -72,7 +72,7 @@ To find the Machine Type for Lenovo devices, check the bottom/back of the device
|
||||
|
||||
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
|
||||
|
||||

|
||||

|
||||
|
||||
## Save Drivers.json
|
||||
|
||||
|
||||
@@ -9,19 +9,17 @@ parent: UI Overview
|
||||
---
|
||||
# Hyper-V Settings
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -7,7 +7,7 @@ parent: UI Overview
|
||||
---
|
||||
# Monitor
|
||||
|
||||

|
||||

|
||||
|
||||
The monitor tab parses the `.\FFUDevelopment\FFUDevelopment.log` file. This makes it easy to track what's happening during the FFU build process.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| -Cleanup | switch | Monitor cancel build action (no direct control) | Switch to run cleanup-only mode. When specified, the script performs cleanup and exits without starting a new build. |
|
||||
| -CleanupAppsISO | bool | Cleanup Apps ISO | When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. |
|
||||
| -CleanupCaptureISO | bool | Cleanup Capture ISO | When set to $true, will remove the WinPE capture ISO after the FFU has been captured. Default is $true. |
|
||||
| -CleanupCurrentRunDownloads | bool | Monitor cancel prompt option (no direct control) | When set to $true, cleanup mode will remove downloads created during the current run and restore backed up run JSON files. Default is $false. |
|
||||
| -CleanupDeployISO | bool | Cleanup Deploy ISO | When set to $true, will remove the WinPE deployment ISO after the FFU has been captured. Default is $true. |
|
||||
| -CleanupDrivers | bool | Cleanup Drivers | When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. |
|
||||
@@ -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. |
|
||||
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
|
||||
| -CopyPPKG | bool | Copy Provisioning Package | When set to $true, will copy the provisioning package from the $FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is $false. |
|
||||
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, 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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -FFUPrefix | string | VM Name Prefix | Prefix for the generated FFU file. Default is _FFU. |
|
||||
| -Headers | hashtable | CLI only (no UI control) | Headers to use when downloading files. Not recommended to modify. |
|
||||
| -InjectUnattend | bool | Inject Unattend.xml | When set to $true and InstallApps is also $true, 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. |
|
||||
| -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. |
|
||||
@@ -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
@@ -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. |
|
||||
| -UserAgent | string | CLI only (no UI control) | User agent string to use when downloading files. |
|
||||
| -UserAppListPath | string | Application Path (derived UserAppList.json) | Path to a JSON file containing a list of user-defined applications to install. Default is $FFUDevelopmentPath\Apps\UserAppList.json. |
|
||||
| -Username | string | Username | Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account. |
|
||||
| -VMHostIPAddress | string | VM Host IP Address | IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this, or use the UI to auto-detect. |
|
||||
| -VMLocation | string | VM Location | Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. |
|
||||
| -VMSwitchName | string | VM Switch Name + Custom VM Switch Name (when Other selected) | Name of the Hyper-V virtual switch 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'. |
|
||||
| -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. |
|
||||
|
||||
@@ -26,7 +26,7 @@ Follow the guide linked below to install Hyper-V on Windows client or Server
|
||||
|
||||
## 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
|
||||
](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)
|
||||
|
||||
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:
|
||||
|
||||
@@ -57,10 +57,10 @@ Replace `C:\FFUDevelopment` with the path you extracted the files to.
|
||||
|
||||
## 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
|
||||
|
||||

|
||||

|
||||
|
||||
{% include page_nav.html %}
|
||||
|
||||
@@ -27,12 +27,6 @@ After following this guide, you will have a USB drive with an FFU that contains
|
||||
|
||||
## 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;">
|
||||
<iframe
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
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**
|
||||
|
||||
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.
|
||||
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%`.
|
||||
|
||||
- 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.
|
||||
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).
|
||||
|
||||
{: .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.
|
||||
|
||||
For the purposes of this quickstart, we'll use **Prompt for Device Name**
|
||||
|
||||
**Post Build Cleanup**
|
||||
|
||||
Leave the Post Build Cleanup section at the defaults
|
||||
|
||||
Your Build tab should look something like this:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
Click **Build FFU**
|
||||
|
||||
@@ -263,6 +217,8 @@ Depending on your internet speed, speed of your build machine, etc. this will ta
|
||||
|
||||
## Monitor
|
||||
|
||||

|
||||
|
||||
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-.**
|
||||
@@ -314,22 +270,18 @@ And the Unattend folder should have an unattend.xml file with the following cont
|
||||
<settings pass="specialize">
|
||||
<!--<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">
|
||||
<ComputerName>*</ComputerName>
|
||||
<ComputerName>MyComputer</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
</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.
|
||||
|
||||
## 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).
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ has_toc: false
|
||||
---
|
||||
# UI Overview
|
||||
|
||||

|
||||

|
||||
|
||||
The user interface has 9 distinct pages for easy navigation.
|
||||
The user interface has 9 distinct tabs for easy navigation.
|
||||
|
||||
@@ -9,7 +9,7 @@ parent: UI Overview
|
||||
---
|
||||
# Updates
|
||||
|
||||

|
||||

|
||||
|
||||
## Update Latest Cumulative Update
|
||||
|
||||
|
||||
@@ -9,21 +9,11 @@ parent: UI Overview
|
||||
---
|
||||
# Windows Settings
|
||||
|
||||

|
||||

|
||||
|
||||
## Windows Media Source
|
||||
## Windows ISO Path
|
||||
|
||||
### Download Windows ESD
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
@@ -31,11 +21,11 @@ Path to Windows ISO file.
|
||||
>
|
||||
> 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ grand_parent: UI Overview
|
||||
---
|
||||
# Install Winget Applications
|
||||
|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
You can also change the architecture, add additional exit codes, or ignore exit codes completely.
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
|
||||