Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15ca246abd | |||
| 6b76f6b9a2 | |||
| 944ec1e856 | |||
| 14811e3f9c | |||
| 38323e6be1 | |||
| 24f10b89b0 | |||
| 0607cf5386 | |||
| 28297f3a0e | |||
| 7bd5decc62 | |||
| f1f1957c43 | |||
| 82bac17b38 | |||
| 1ea1ef6fd0 | |||
| 5ca5312c52 | |||
| 4a2d8e63ea | |||
| 78212f06d7 | |||
| f838ef3779 | |||
| 5aaa1ad732 | |||
| c135ad0fba | |||
| 6db0f8c905 | |||
| b62a1a19b0 | |||
| d6361dac4d | |||
| bae29fd9c7 | |||
| aca968ccbd | |||
| db044551cc | |||
| d6e6287b56 | |||
| 98c1644d76 | |||
| 002017e5e6 | |||
| 2d2ce49537 | |||
| 42ed2819b8 | |||
| 80147ed61b | |||
| b28344d272 | |||
| 7678f61480 | |||
| eac8be3d31 | |||
| 3cb9bc9931 | |||
| b388eae439 | |||
| c1b81004be | |||
| 02e423c0f2 | |||
| ce34e40a52 | |||
| 4f701c4b1c | |||
| 6c0ee8abc5 | |||
| 9bacac8f3d | |||
| a0dc5a6ae9 | |||
| 6b548a34e6 | |||
| 8c5629c9ce | |||
| e7589f6ceb | |||
| 711582ae71 | |||
| db22c1801d | |||
| 422bc33da7 | |||
| 96603f025a | |||
| a8fecd133e | |||
| 7f10811c05 | |||
| f09c98906a | |||
| 04dfb5f327 | |||
| dc801e9cc9 | |||
| a8e2ab941f | |||
| c83bc8c769 | |||
| 0eb7f66c2b | |||
| d70615a32d | |||
| 26694f30e3 | |||
| 3563639ce0 | |||
| a42f49e1fa | |||
| 42b0b0c350 | |||
| 6e6abfe833 | |||
| baa696b880 | |||
| 27eebeb9cb | |||
| d349e5e4fb | |||
| 53a47511d8 | |||
| 518b4d4e62 | |||
| a771136761 | |||
| 36ee6f64bc | |||
| 2257b72255 | |||
| a65c9b5a18 | |||
| d38e461246 | |||
| edc9901e7e | |||
| 48b55df18e | |||
| cf9c605c34 | |||
| d6e7fd314f | |||
| a193c283f3 | |||
| 477d51fbbb | |||
| 3e3492bbab | |||
| 97e1ec2be4 | |||
| 6e95ff92b1 | |||
| d4274d54d2 | |||
| aee33a6a4b | |||
| 25a0928195 |
@@ -1,5 +1,91 @@
|
||||
# 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,3 +1,11 @@
|
||||
# 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
|
||||
@@ -247,11 +255,6 @@ 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 = @()
|
||||
@@ -286,9 +289,9 @@ if ($wingetApps.Count -gt 0) {
|
||||
Install-Applications -apps $wingetApps
|
||||
}
|
||||
|
||||
# Read the UserAppList.json file if it exists
|
||||
# Read the configured BYO app list file if it exists
|
||||
if (Test-Path -Path $userAppsJsonFile) {
|
||||
Write-Host "Processing UserAppList.json..."
|
||||
Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
|
||||
try {
|
||||
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
if ($userContent -is [array]) {
|
||||
@@ -296,19 +299,19 @@ if (Test-Path -Path $userAppsJsonFile) {
|
||||
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
||||
}
|
||||
elseif ($userContent) {
|
||||
$userApps = @($userContent) # Ensure it's an array
|
||||
$userApps = @($userContent)
|
||||
Write-Host "Found 1 user-defined app."
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse UserAppList.json file: $_"
|
||||
Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json file not found. Skipping."
|
||||
Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping."
|
||||
}
|
||||
|
||||
# Install User apps if any were found
|
||||
|
||||
@@ -28,8 +28,26 @@ Write-Host "---------------------------------------------------" -ForegroundColo
|
||||
# Define the path to the scripts
|
||||
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
# Define the list of scripts to run, order doesn't matter - if you have a custom script, add it here
|
||||
# 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",
|
||||
"Update-Defender.ps1",
|
||||
"Install-Office.ps1",
|
||||
"Update-MSRT.ps1",
|
||||
@@ -50,7 +68,6 @@ 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
|
||||
}
|
||||
@@ -68,10 +85,15 @@ 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
|
||||
# Run script and wait for it to finish.
|
||||
if ($script -eq "Install-Win32Apps.ps1") {
|
||||
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
|
||||
}
|
||||
else {
|
||||
& $scriptFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present
|
||||
$appsScriptFile = Join-Path -Path $scriptPath -ChildPath "Invoke-AppsScript.ps1"
|
||||
|
||||
@@ -46,7 +46,8 @@ $script:uiState = [PSCustomObject]@{
|
||||
logStreamReader = $null;
|
||||
pollTimer = $null;
|
||||
currentBuildProcess = $null;
|
||||
lastConfigFilePath = $null
|
||||
lastConfigFilePath = $null;
|
||||
loadedDeviceNamingMode = $null
|
||||
};
|
||||
Flags = @{
|
||||
installAppsForcedByUpdates = $false;
|
||||
@@ -55,7 +56,10 @@ $script:uiState = [PSCustomObject]@{
|
||||
lastSortProperty = $null;
|
||||
lastSortAscending = $true;
|
||||
isBuilding = $false;
|
||||
isCleanupRunning = $false
|
||||
isCleanupRunning = $false;
|
||||
isFluentSupported = $false;
|
||||
deviceNamingModeWasExplicitlyChanged = $false;
|
||||
suppressDeviceNamingChangeTracking = $false
|
||||
};
|
||||
Defaults = @{};
|
||||
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
|
||||
@@ -120,6 +124,9 @@ $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
|
||||
@@ -129,6 +136,9 @@ $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
|
||||
@@ -295,6 +305,7 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$startCleanupParams = @{
|
||||
FilePath = $pwshPath
|
||||
ArgumentList = $cleanupArgs
|
||||
WorkingDirectory = $ffuDevPath
|
||||
PassThru = $true
|
||||
}
|
||||
if ($Host.Name -eq 'ConsoleHost') {
|
||||
@@ -389,8 +400,11 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
# Not currently building: start a new build
|
||||
$btnRun.IsEnabled = $false
|
||||
|
||||
# Switch to Monitor Tab
|
||||
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
|
||||
# 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
|
||||
}
|
||||
|
||||
# Clear previous log data and reset autoscroll
|
||||
if ($null -ne $script:uiState.Data.logData) {
|
||||
@@ -414,6 +428,123 @@ $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]@{}
|
||||
@@ -457,6 +588,7 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$startBuildParams = @{
|
||||
FilePath = $pwshPath
|
||||
ArgumentList = $pwshArgs
|
||||
WorkingDirectory = $config.FFUDevelopmentPath
|
||||
PassThru = $true
|
||||
}
|
||||
if ($Host.Name -eq 'ConsoleHost') {
|
||||
|
||||
@@ -3,11 +3,8 @@ param (
|
||||
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
|
||||
[string]$WindowsArch = 'x64',
|
||||
[bool]$CopyPEDrivers = $false,
|
||||
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
|
||||
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
|
||||
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
|
||||
[bool]$Capture,
|
||||
[bool]$Deploy = $true
|
||||
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
|
||||
)
|
||||
|
||||
function WriteLog($LogText) {
|
||||
@@ -77,12 +74,7 @@ function Invoke-Process {
|
||||
}
|
||||
|
||||
function New-PEMedia {
|
||||
param (
|
||||
[Parameter()]
|
||||
[bool]$Capture,
|
||||
[Parameter()]
|
||||
[bool]$Deploy
|
||||
)
|
||||
param ()
|
||||
#Need to use the Demployment and Imaging tools environment to create winPE media
|
||||
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
|
||||
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
|
||||
@@ -135,17 +127,6 @@ function New-PEMedia {
|
||||
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
|
||||
WriteLog "Adding package complete"
|
||||
}
|
||||
If ($Capture) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
|
||||
WriteLog "Copy complete"
|
||||
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
|
||||
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
|
||||
# $WinPEISOName = 'WinPE_FFU_Capture.iso'
|
||||
$WinPEISOFile = $CaptureISO
|
||||
# $Capture = $false
|
||||
}
|
||||
If ($Deploy) {
|
||||
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
|
||||
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
|
||||
WriteLog 'Copy complete'
|
||||
@@ -160,11 +141,7 @@ function New-PEMedia {
|
||||
}
|
||||
WriteLog "Adding drivers complete"
|
||||
}
|
||||
# $WinPEISOName = 'WinPE_FFU_Deploy.iso'
|
||||
$WinPEISOFile = $DeployISO
|
||||
|
||||
# $Deploy = $false
|
||||
}
|
||||
WriteLog 'Dismounting WinPE media'
|
||||
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
|
||||
WriteLog 'Dismount complete'
|
||||
@@ -179,31 +156,15 @@ function New-PEMedia {
|
||||
WriteLog "Creating WinPE ISO at $WinPEISOFile"
|
||||
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
|
||||
if($WindowsArch -eq 'x64'){
|
||||
if($Capture){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
if($Deploy){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
}
|
||||
elseif($WindowsArch -eq 'arm64'){
|
||||
if($Capture){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
if($Deploy){
|
||||
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
|
||||
}
|
||||
|
||||
}
|
||||
Invoke-Process $OSCDIMG $OSCDIMGArgs
|
||||
WriteLog "ISO created successfully"
|
||||
WriteLog "Cleaning up $WinPEFFUPath"
|
||||
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
|
||||
WriteLog 'Cleanup complete'
|
||||
}
|
||||
if($Capture){
|
||||
New-PEMedia -Capture $Capture
|
||||
}
|
||||
if($Deploy){
|
||||
New-PEMedia -Deploy $Deploy
|
||||
}
|
||||
New-PEMedia
|
||||
@@ -85,12 +85,10 @@ graph TD
|
||||
subgraph "VM-Based Capture (-InstallApps)"
|
||||
direction LR
|
||||
BB[Create Hyper-V VM from VHDX];
|
||||
BB --> BC["Create WinPE Capture Media iso"];
|
||||
BC --> BD[Configure network share for capture];
|
||||
BD --> BE["Start VM: Boots to Audit Mode"];
|
||||
BB --> BE["Start VM: Boots to Audit Mode"];
|
||||
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
|
||||
BF --> BG[VM reboots from Capture Media];
|
||||
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
|
||||
BF --> BG[Host optimizes and remounts VHDX];
|
||||
BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
|
||||
end
|
||||
|
||||
subgraph "Direct VHDX Capture"
|
||||
|
||||
@@ -6,28 +6,23 @@ 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,
|
||||
[bool]$RemoveFFU = $false,
|
||||
[bool]$RemoveApps = $false,
|
||||
[bool]$RemoveUpdates = $false
|
||||
[bool]$RemoveUpdates = $false,
|
||||
[bool]$RemoveDownloadedESD = $false
|
||||
)
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
try {
|
||||
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates KBPath=$KBPath)."
|
||||
WriteLog "CommonCleanup: Starting cleanup (DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
|
||||
|
||||
# Primary ISO paths (new naming/location)
|
||||
if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $CaptureISOPath"
|
||||
try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $DeployISOPath"
|
||||
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
|
||||
@@ -38,11 +33,6 @@ function Invoke-FFUPostBuildCleanup {
|
||||
}
|
||||
|
||||
# Legacy / root-level WinPE ISOs (pattern-based)
|
||||
if ($RemoveCaptureISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if ($RemoveDeployISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
@@ -95,6 +85,15 @@ function Invoke-FFUPostBuildCleanup {
|
||||
}
|
||||
}
|
||||
|
||||
# Always remove LTSC update staging folder (out-of-band cleanup exception)
|
||||
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
|
||||
$ltscUpdateFolder = Join-Path $AppsPath 'LTSCUpdate'
|
||||
if (Test-Path -LiteralPath $ltscUpdateFolder) {
|
||||
WriteLog "CommonCleanup: Removing LTSC update staging folder $ltscUpdateFolder"
|
||||
try { Remove-Item -LiteralPath $ltscUpdateFolder -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $ltscUpdateFolder : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveUpdates) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
|
||||
# Remove per-run app update payloads stored under Apps
|
||||
@@ -114,6 +113,20 @@ function Invoke-FFUPostBuildCleanup {
|
||||
}
|
||||
}
|
||||
|
||||
# Remove downloaded ESD files from the root path when requested
|
||||
if ($RemoveDownloadedESD -and -not [string]::IsNullOrWhiteSpace($RootPath) -and (Test-Path -LiteralPath $RootPath -PathType Container)) {
|
||||
WriteLog "CommonCleanup: Removing downloaded ESD files in $RootPath"
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter *.esd -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try {
|
||||
WriteLog "CommonCleanup: Removing ESD $($_.FullName)"
|
||||
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "CommonCleanup: Failed removing ESD $($_.FullName) : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "CommonCleanup: Completed."
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -157,6 +157,79 @@ function Invoke-Process {
|
||||
return $cmd
|
||||
}
|
||||
|
||||
function Get-RunManifestPathForDownloadTarget {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
try {
|
||||
$currentPath = Split-Path -Path $Destination -Parent
|
||||
if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null }
|
||||
|
||||
while ($currentPath) {
|
||||
$manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json'
|
||||
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
|
||||
return $manifestPath
|
||||
}
|
||||
|
||||
$parentPath = Split-Path -Path $currentPath -Parent
|
||||
if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) {
|
||||
break
|
||||
}
|
||||
$currentPath = $parentPath
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Register-CurrentRunDownloadTarget {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Destination)) { return }
|
||||
|
||||
$manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination
|
||||
if ([string]::IsNullOrWhiteSpace($manifestPath)) { return }
|
||||
|
||||
$mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex'
|
||||
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
|
||||
|
||||
try {
|
||||
$null = $mutex.WaitOne()
|
||||
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
||||
if ($null -eq $manifest) { return }
|
||||
|
||||
if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) {
|
||||
Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @()
|
||||
}
|
||||
|
||||
$downloadTargets = @($manifest.DownloadTargets)
|
||||
if ($Destination -notin $downloadTargets) {
|
||||
$downloadTargets += $Destination
|
||||
$manifest.DownloadTargets = $downloadTargets
|
||||
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||
WriteLog "Registered current-run download target: $Destination"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)"
|
||||
}
|
||||
finally {
|
||||
try { $mutex.ReleaseMutex() | Out-Null } catch {}
|
||||
$mutex.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Function to download a file using BITS with retry and error handling
|
||||
function Start-BitsTransferWithRetry {
|
||||
param (
|
||||
@@ -181,6 +254,10 @@ function Start-BitsTransferWithRetry {
|
||||
}
|
||||
}
|
||||
|
||||
# Register destination so cancel cleanup can remove this run's downloaded files
|
||||
# even when file timestamps are inherited from the source.
|
||||
Register-CurrentRunDownloadTarget -Destination $Destination
|
||||
|
||||
$attempt = 0
|
||||
$lastError = $null
|
||||
$notLoggedOnHResult = [int]0x800704dd
|
||||
|
||||
@@ -166,6 +166,175 @@ function ConvertTo-SurfaceComparableName {
|
||||
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 {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -586,6 +755,9 @@ Export-ModuleMember -Function `
|
||||
Import-SurfaceDriverIndexCache, `
|
||||
Save-SurfaceDriverIndexCache, `
|
||||
ConvertTo-SurfaceComparableName, `
|
||||
ConvertTo-SurfaceHtmlText, `
|
||||
ConvertTo-SurfaceDownloadCenterLink, `
|
||||
Get-SurfaceDriverModelIndex, `
|
||||
Get-SurfaceSystemSkuReferenceIndex, `
|
||||
Get-SurfaceDownloadCenterDetails, `
|
||||
Get-SurfaceSystemSkuListForMicrosoftDriver
|
||||
@@ -161,6 +161,7 @@ function Invoke-ParallelProcessing {
|
||||
ApplicationItemData = $currentItem
|
||||
AppListJsonPath = $localJobArgs['AppListJsonPath']
|
||||
AppsPath = $localJobArgs['AppsPath']
|
||||
UserAppListPath = $localJobArgs['UserAppListPath']
|
||||
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
ProgressQueue = $localProgressQueue
|
||||
WindowsArch = $localJobArgs['WindowsArch']
|
||||
|
||||
@@ -358,6 +358,8 @@ function Start-WingetAppDownloadTask {
|
||||
[string]$AppListJsonPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter()]
|
||||
[string]$UserAppListPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -379,11 +381,11 @@ function Start-WingetAppDownloadTask {
|
||||
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||
|
||||
try {
|
||||
# Define paths
|
||||
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
|
||||
# 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" }
|
||||
$appFound = $false
|
||||
|
||||
# 1. Check UserAppList.json and content
|
||||
# 1. Check the configured BYO app list and content
|
||||
if (Test-Path -Path $userAppListPath) {
|
||||
try {
|
||||
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
|
||||
@@ -724,6 +726,8 @@ function Get-Apps {
|
||||
[string]$AppList,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter()]
|
||||
[string]$UserAppListPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -787,6 +791,7 @@ 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,6 +27,22 @@ 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()]
|
||||
@@ -76,10 +92,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 UserAppList.json 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 the configured BYO app list now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
|
||||
if ($result -eq 'Yes') {
|
||||
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
|
||||
$userAppListPath = Get-BYOApplicationListPath -State $State
|
||||
Save-BYOApplicationList -Path $userAppListPath -State $State
|
||||
}
|
||||
}
|
||||
@@ -166,6 +182,7 @@ function Add-BYOApplication {
|
||||
|
||||
# Refresh the ListView to show the changes
|
||||
$listView.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
|
||||
# Reset state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
@@ -196,6 +213,7 @@ function Add-BYOApplication {
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($application)
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
}
|
||||
|
||||
# Clear form and update button states for both add and update operations
|
||||
@@ -269,6 +287,7 @@ function Add-AppsScriptVariable {
|
||||
}
|
||||
$State.Data.appsScriptVariablesDataList.Add($newItem)
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
|
||||
$State.Controls.txtAppsScriptKey.Clear()
|
||||
$State.Controls.txtAppsScriptValue.Clear()
|
||||
# Update the header checkbox state
|
||||
@@ -295,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
|
||||
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
|
||||
}
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
|
||||
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
@@ -391,18 +411,24 @@ function Invoke-CopyBYOApps {
|
||||
)
|
||||
|
||||
$localAppsPath = $State.Controls.txtApplicationPath.Text
|
||||
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
|
||||
$userAppListPath = Get-BYOApplicationListPath -State $State
|
||||
$listView = $State.Controls.lstApplications
|
||||
|
||||
try {
|
||||
# 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 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.
|
||||
$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 UserAppList.json with all applications from the UI."
|
||||
WriteLog "Successfully updated BYO app list at $userAppListPath with all applications from the UI."
|
||||
}
|
||||
catch {
|
||||
$errorMessage = "Failed to update UserAppList.json: $_"
|
||||
$errorMessage = "Failed to update BYO app list at $($userAppListPath): $_"
|
||||
WriteLog $errorMessage
|
||||
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
|
||||
return
|
||||
|
||||
@@ -25,7 +25,6 @@ function Get-UIConfig {
|
||||
else { $null }
|
||||
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
|
||||
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
|
||||
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
|
||||
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
|
||||
CompactOS = $State.Controls.chkCompactOS.IsChecked
|
||||
@@ -37,15 +36,23 @@ 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
|
||||
@@ -54,6 +61,7 @@ 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
|
||||
@@ -82,7 +90,7 @@ function Get-UIConfig {
|
||||
RemoveApps = $State.Controls.chkRemoveApps.IsChecked
|
||||
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
|
||||
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
|
||||
ShareName = $State.Controls.txtShareName.Text
|
||||
RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
|
||||
UpdateADK = $State.Controls.chkUpdateADK.IsChecked
|
||||
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
|
||||
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
|
||||
@@ -92,14 +100,13 @@ function Get-UIConfig {
|
||||
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
|
||||
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
|
||||
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
|
||||
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json"
|
||||
UserAppListPath = $State.Controls.txtUserAppListPath.Text
|
||||
USBDriveList = @{}
|
||||
Username = $State.Controls.txtUsername.Text
|
||||
Threads = [int]$State.Controls.txtThreads.Text
|
||||
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
|
||||
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
|
||||
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
|
||||
Verbose = $State.Controls.chkVerbose.IsChecked
|
||||
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
|
||||
VMLocation = $State.Controls.txtVMLocation.Text
|
||||
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$State.Controls.txtCustomVMSwitchName.Text
|
||||
@@ -411,7 +418,6 @@ function Select-VMSwitchFromConfig {
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
|
||||
$State.Data.customVMSwitchName = $configSwitch
|
||||
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
|
||||
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
|
||||
}
|
||||
}
|
||||
@@ -426,12 +432,19 @@ 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
|
||||
@@ -443,37 +456,90 @@ 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
|
||||
Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State
|
||||
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
|
||||
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'))) {
|
||||
@@ -583,6 +649,7 @@ 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
|
||||
@@ -646,6 +713,7 @@ function Update-UIFromConfig {
|
||||
}
|
||||
# Update the ListView's ItemsSource after populating the data list
|
||||
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
|
||||
# Update the header checkbox state
|
||||
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
|
||||
@@ -714,6 +782,7 @@ function Update-UIFromConfig {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstUSBDrives.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
|
||||
|
||||
# Update the Select All header checkbox state
|
||||
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
|
||||
@@ -774,6 +843,7 @@ function Update-UIFromConfig {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstAdditionalFFUs.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
|
||||
@@ -833,7 +903,7 @@ function Invoke-RestoreDefaults {
|
||||
$rootPath = $State.FFUDevelopmentPath
|
||||
|
||||
# Normalize potential array values to single strings
|
||||
function Normalize-PathScalar {
|
||||
function Get-PathScalar {
|
||||
param([object]$value)
|
||||
if ($null -eq $value) { return $null }
|
||||
if ($value -is [System.Array]) {
|
||||
@@ -848,21 +918,20 @@ function Invoke-RestoreDefaults {
|
||||
}
|
||||
|
||||
$appsPath = Join-Path $rootPath 'Apps'
|
||||
$driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
|
||||
$driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text
|
||||
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
|
||||
$driversPath = Join-Path $rootPath 'Drivers'
|
||||
}
|
||||
else {
|
||||
$driversPath = $driversRaw
|
||||
}
|
||||
$ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
|
||||
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
|
||||
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
|
||||
|
||||
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
|
||||
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
|
||||
$appsISOPath = Join-Path $rootPath 'Apps.iso'
|
||||
|
||||
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Capture, Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
|
||||
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
|
||||
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
|
||||
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
|
||||
WriteLog "RestoreDefaults: User cancelled."
|
||||
@@ -898,17 +967,16 @@ 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 `
|
||||
-RemoveFFU:$true `
|
||||
-RemoveApps:$true `
|
||||
-RemoveUpdates:$true
|
||||
-RemoveUpdates:$true `
|
||||
-RemoveDownloadedESD:$true
|
||||
|
||||
# Clear UI lists / state
|
||||
if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() }
|
||||
@@ -1036,6 +1104,7 @@ function Import-ConfigSupplementalAssets {
|
||||
})
|
||||
}
|
||||
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
|
||||
$loadedWinget = $true
|
||||
if ($null -ne $State.Controls.wingetSearchPanel) {
|
||||
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
|
||||
@@ -1170,6 +1239,7 @@ function Import-ConfigSupplementalAssets {
|
||||
}
|
||||
}
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
|
||||
$headerChk = $State.Controls.chkSelectAllDriverModels
|
||||
if ($null -ne $headerChk) {
|
||||
|
||||
@@ -212,6 +212,14 @@ function Save-DellDriversTask {
|
||||
Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" }
|
||||
|
||||
# Track extracted model XML so cancel cleanup can remove it even if file timestamps are preserved from source metadata.
|
||||
try {
|
||||
Register-CurrentRunDownloadTarget -Destination $modelXmlPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to register Dell model XML for current-run cleanup ($modelXmlPath): $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
|
||||
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ function Get-LenovoDriversModelList {
|
||||
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
|
||||
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
|
||||
|
||||
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
|
||||
$lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
|
||||
|
||||
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
|
||||
$lenovoCookie = Get-LenovoPSREFToken
|
||||
# 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
|
||||
# $lenovoCookie = Get-LenovoPSREFToken
|
||||
|
||||
# Add the cookie to the headers
|
||||
$Headers["Cookie"] = $lenovoCookie
|
||||
|
||||
@@ -15,100 +15,8 @@ function Get-MicrosoftDriversModelList {
|
||||
[string]$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."
|
||||
}
|
||||
# Keep the UI signature unchanged while using the shared Learn-based source.
|
||||
return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
|
||||
}
|
||||
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
|
||||
function Save-MicrosoftDriversTask {
|
||||
|
||||
@@ -38,6 +38,37 @@ function Get-DriverDisplayName {
|
||||
return "$($BaseName.Trim()) ($($Identifier.Trim()))"
|
||||
}
|
||||
|
||||
function Get-EffectiveDriverWindowsRelease {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[string]$WindowsReleaseDisplay,
|
||||
[string]$WindowsSku
|
||||
)
|
||||
|
||||
# Normalize LTSC/LTSB UI release selections to client driver releases for OEM catalogs.
|
||||
if (-not [string]::IsNullOrWhiteSpace($WindowsReleaseDisplay)) {
|
||||
if (($WindowsReleaseDisplay -like 'Windows 10*') -and (($WindowsReleaseDisplay -like '*LTSB*') -or ($WindowsReleaseDisplay -like '*LTSC*'))) {
|
||||
return 10
|
||||
}
|
||||
if (($WindowsReleaseDisplay -like 'Windows 11*') -and ($WindowsReleaseDisplay -like '*LTSC*')) {
|
||||
return 11
|
||||
}
|
||||
}
|
||||
|
||||
# Use SKU-based fallback when display text is unavailable.
|
||||
if (-not [string]::IsNullOrWhiteSpace($WindowsSku) -and $WindowsSku -like '*LTS*') {
|
||||
if ($WindowsRelease -in 2016, 2019, 2021) {
|
||||
return 10
|
||||
}
|
||||
if ($WindowsRelease -eq 2024) {
|
||||
return 11
|
||||
}
|
||||
}
|
||||
|
||||
return $WindowsRelease
|
||||
}
|
||||
|
||||
function Convert-DriverItemToJsonModel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -154,8 +185,20 @@ function Convert-DriverItemToJsonModel {
|
||||
# Get necessary values from UI or script scope
|
||||
$localDriversFolder = $State.Controls.txtDriversFolder.Text
|
||||
$localWindowsRelease = $null
|
||||
$localWindowsReleaseDisplay = $null
|
||||
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
|
||||
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
|
||||
}
|
||||
|
||||
# Resolve effective release used specifically for OEM driver operations.
|
||||
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
|
||||
$localDriverWindowsRelease = $localWindowsRelease
|
||||
if ($null -ne $localWindowsRelease) {
|
||||
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
|
||||
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
|
||||
WriteLog "Normalized WindowsRelease for model retrieval from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
|
||||
}
|
||||
}
|
||||
|
||||
# Get headers and user agent from Get-CoreStaticVariables
|
||||
@@ -173,7 +216,7 @@ function Convert-DriverItemToJsonModel {
|
||||
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder
|
||||
}
|
||||
'Dell' {
|
||||
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
|
||||
$rawModels = Get-DellDriversModelList -WindowsRelease $localDriverWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
|
||||
}
|
||||
'HP' {
|
||||
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
|
||||
@@ -330,6 +373,7 @@ function Search-DriverModels {
|
||||
}
|
||||
|
||||
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
$filteredCount = 0
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($item in $collectionView) { $filteredCount++ }
|
||||
@@ -344,7 +388,10 @@ function Save-DriversJson {
|
||||
[psobject]$State
|
||||
)
|
||||
WriteLog "Save-DriversJson function called."
|
||||
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
|
||||
|
||||
# Save from the master model list so filtered-out selected rows are preserved.
|
||||
$driverSelectionSource = if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels } else { $State.Controls.lstDriverModels.Items }
|
||||
$selectedDrivers = @($driverSelectionSource | Where-Object { $_.IsSelected })
|
||||
|
||||
if (-not $selectedDrivers) {
|
||||
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
@@ -669,6 +716,7 @@ function Import-DriversJson {
|
||||
|
||||
# Update the UI and apply any existing filter
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
|
||||
|
||||
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
|
||||
@@ -740,6 +788,7 @@ function Invoke-GetModels {
|
||||
|
||||
# Update the UI ItemsSource to point to the new list and clear the filter
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
|
||||
$State.Controls.txtModelFilter.Text = ""
|
||||
|
||||
if ($State.Data.allDriverModels.Count -gt 0) {
|
||||
@@ -831,6 +880,12 @@ function Invoke-DownloadSelectedDrivers {
|
||||
|
||||
$localDriversFolder = $State.Controls.txtDriversFolder.Text
|
||||
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
|
||||
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
|
||||
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
|
||||
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
|
||||
WriteLog "Normalized WindowsRelease for driver download from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
|
||||
}
|
||||
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||
$localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null }
|
||||
$coreStaticVars = Get-CoreStaticVariables
|
||||
@@ -848,10 +903,10 @@ function Invoke-DownloadSelectedDrivers {
|
||||
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
|
||||
try {
|
||||
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
|
||||
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
|
||||
$catalogBaseName = if ($localDriverWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
|
||||
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||
$catalogUrl = if ($localWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||
$catalogUrl = if ($localDriverWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||
|
||||
$downloadCatalog = $true
|
||||
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
|
||||
@@ -891,7 +946,7 @@ function Invoke-DownloadSelectedDrivers {
|
||||
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
|
||||
$taskArguments = @{
|
||||
DriversFolder = $localDriversFolder
|
||||
WindowsRelease = $localWindowsRelease
|
||||
WindowsRelease = $localDriverWindowsRelease
|
||||
WindowsArch = $localWindowsArch
|
||||
WindowsVersion = $localWindowsVersion
|
||||
Headers = $localHeaders
|
||||
|
||||
@@ -5,6 +5,341 @@
|
||||
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..."
|
||||
@@ -24,7 +359,7 @@ function Register-EventHandlers {
|
||||
|
||||
# Define a handler to validate pasted text, ensuring it's only integers
|
||||
$integerPastingHandler = {
|
||||
param($sender, $pastingEventArgs)
|
||||
param($eventSource, $pastingEventArgs)
|
||||
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
|
||||
$pastedText = $pastingEventArgs.DataObject.GetData([string])
|
||||
# Check if the pasted text consists ONLY of one or more digits.
|
||||
@@ -87,6 +422,132 @@ 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)
|
||||
@@ -94,7 +555,34 @@ 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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,19 +596,246 @@ 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()
|
||||
@@ -220,6 +935,7 @@ function Register-EventHandlers {
|
||||
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
|
||||
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
|
||||
}
|
||||
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
|
||||
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
|
||||
$localState.Controls.lstUSBDrives.SelectedIndex = 0
|
||||
}
|
||||
@@ -253,42 +969,30 @@ function Register-EventHandlers {
|
||||
})
|
||||
|
||||
# Hyper-V tab event handlers
|
||||
$State.Controls.chkEnableVMNetworking.Add_Checked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Update-VMNetworkingControls -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.chkEnableVMNetworking.Add_Unchecked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Update-VMNetworkingControls -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.cmbVMSwitchName.Add_SelectionChanged({
|
||||
param($eventSource, $selectionChangedEventArgs)
|
||||
# The state object is available via the parent window's Tag property
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
$selectedItem = $eventSource.SelectedItem
|
||||
if ($selectedItem -eq 'Other') {
|
||||
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
|
||||
$localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Update-VMNetworkingControls -State $localState
|
||||
})
|
||||
|
||||
# Persist custom VM switch name/IP when user edits them while 'Other' is selected
|
||||
$State.Controls.txtVMHostIPAddress.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
|
||||
}
|
||||
})
|
||||
# Persist custom VM switch name when user edits it while 'Other' is selected
|
||||
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -299,12 +1003,31 @@ function Register-EventHandlers {
|
||||
})
|
||||
|
||||
# Windows Settings tab Event Handlers
|
||||
$State.Controls.txtISOPath.Add_TextChanged({
|
||||
param($eventSource, $textChangedEventArgs)
|
||||
# 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
|
||||
Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||
$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.cmbWindowsRelease.Add_SelectionChanged({
|
||||
param($eventSource, $selectionChangedEventArgs)
|
||||
@@ -314,9 +1037,18 @@ function Register-EventHandlers {
|
||||
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
|
||||
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value
|
||||
}
|
||||
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $localState.Controls.txtISOPath.Text -State $localState
|
||||
# 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-WindowsSkuCombo -State $localState
|
||||
Update-WindowsArchCombo -State $localState
|
||||
|
||||
# Re-evaluate Install Apps dependency when Windows release changes
|
||||
Update-InstallAppsState -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.cmbWindowsVersion.Add_SelectionChanged({
|
||||
@@ -369,6 +1101,8 @@ function Register-EventHandlers {
|
||||
$State.Controls.chkUpdateOneDrive.Add_Unchecked($updateCheckboxHandler)
|
||||
$State.Controls.chkUpdateLatestMSRT.Add_Checked($updateCheckboxHandler)
|
||||
$State.Controls.chkUpdateLatestMSRT.Add_Unchecked($updateCheckboxHandler)
|
||||
$State.Controls.chkUpdateLatestCU.Add_Checked($updateCheckboxHandler)
|
||||
$State.Controls.chkUpdateLatestCU.Add_Unchecked($updateCheckboxHandler)
|
||||
|
||||
# Also attach the handler to the Office checkbox
|
||||
$State.Controls.chkInstallOffice.Add_Checked($updateCheckboxHandler)
|
||||
@@ -430,10 +1164,18 @@ function Register-EventHandlers {
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select AppList.json File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
|
||||
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select Winget AppList 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)
|
||||
@@ -461,17 +1203,23 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||
# 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 }
|
||||
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 Application List" `
|
||||
-Title "Save BYO App List" `
|
||||
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
|
||||
-InitialDirectory $initialDir `
|
||||
-FileName "UserAppList.json" `
|
||||
-FileName $fileName `
|
||||
-DefaultExt ".json"
|
||||
|
||||
if ($savePath) { Save-BYOApplicationList -Path $savePath -State $localState }
|
||||
if ($savePath) {
|
||||
$localState.Controls.txtUserAppListPath.Text = $savePath
|
||||
Save-BYOApplicationList -Path $savePath -State $localState
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.btnLoadBYOApplications.Add_Click({
|
||||
@@ -479,15 +1227,18 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
$initialDir = $localState.Controls.txtApplicationPath.Text
|
||||
# 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 }
|
||||
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
|
||||
|
||||
$loadPath = Invoke-BrowseAction -Type 'OpenFile' `
|
||||
-Title "Import Application List" `
|
||||
-Title "Import BYO App 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,6 +13,97 @@
|
||||
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..."
|
||||
@@ -21,6 +112,9 @@ 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')
|
||||
@@ -70,8 +164,10 @@ 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')
|
||||
@@ -109,32 +205,51 @@ 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')
|
||||
$State.Controls.chkRemoveDownloadedESD = $window.FindName('chkRemoveDownloadedESD')
|
||||
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
|
||||
$State.Controls.txtMemory = $window.FindName('txtMemory')
|
||||
$State.Controls.txtProcessors = $window.FindName('txtProcessors')
|
||||
@@ -158,6 +273,7 @@ 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')
|
||||
@@ -179,11 +295,59 @@ function Initialize-UIControls {
|
||||
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
|
||||
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
|
||||
|
||||
# Monitor Tab
|
||||
$State.Controls.MainTabControl = $window.FindName('MainTabControl')
|
||||
$State.Controls.MonitorTab = $window.FindName('MonitorTab')
|
||||
# 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')
|
||||
$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
|
||||
@@ -204,19 +368,11 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,8 +388,6 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
|
||||
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
|
||||
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
|
||||
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
|
||||
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
|
||||
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
|
||||
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
|
||||
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
|
||||
@@ -243,7 +397,8 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
|
||||
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
|
||||
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
|
||||
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
|
||||
$State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
|
||||
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
|
||||
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
|
||||
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
|
||||
@@ -251,15 +406,29 @@ 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
|
||||
$State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps
|
||||
$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
|
||||
@@ -268,7 +437,9 @@ function Initialize-UIDefaults {
|
||||
Update-BitsPrioritySetting -State $State
|
||||
|
||||
# Hyper-V Settings defaults from General Defaults
|
||||
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
|
||||
Initialize-VMSwitchData -State $State
|
||||
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
|
||||
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
|
||||
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
|
||||
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
|
||||
@@ -277,7 +448,12 @@ 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
|
||||
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State
|
||||
# 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
|
||||
|
||||
# Windows Settings tab defaults
|
||||
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
|
||||
@@ -303,6 +479,7 @@ 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
|
||||
|
||||
@@ -343,6 +520,31 @@ 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
|
||||
|
||||
@@ -351,23 +553,48 @@ function Initialize-UIDefaults {
|
||||
|
||||
# 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
$driverModelsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
|
||||
|
||||
# Add the selectable column using the new function
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70
|
||||
# Add the selectable column and scope header select-all to visible filtered rows.
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 -HeaderSelectionAffectsVisibleItemsOnly
|
||||
|
||||
# Add other sortable columns with left-aligned headers
|
||||
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
|
||||
@@ -389,12 +616,16 @@ 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
|
||||
|
||||
@@ -442,9 +673,11 @@ 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
|
||||
# Create a style to disable the ComboBox for 'msstore' source, inheriting the Fluent base style
|
||||
$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")
|
||||
@@ -545,12 +778,16 @@ 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
|
||||
|
||||
@@ -567,12 +804,16 @@ 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
|
||||
|
||||
@@ -619,6 +860,9 @@ function Initialize-DynamicUIElements {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Keep apps script variable columns sized to the current visible content.
|
||||
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
@@ -635,6 +879,7 @@ 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
|
||||
|
||||
@@ -691,6 +936,9 @@ function Initialize-DynamicUIElements {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Keep USB drive columns sized to the current visible content.
|
||||
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
@@ -698,6 +946,7 @@ 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
|
||||
|
||||
@@ -736,6 +985,9 @@ function Initialize-DynamicUIElements {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Keep additional FFU columns sized to the current visible content.
|
||||
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
|
||||
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
|
||||
}
|
||||
}
|
||||
$ListView.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
}
|
||||
|
||||
# Function to move selected item to the top
|
||||
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
|
||||
if ($null -ne $itemToUpdate) {
|
||||
$itemToUpdate.$StatusProperty = $StatusValue
|
||||
$ListView.Items.Refresh() # Refresh the view to show the change
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
}
|
||||
else {
|
||||
# Log if item not found (for debugging)
|
||||
@@ -329,7 +331,8 @@ function Add-SelectableGridViewColumn {
|
||||
[string]$HeaderCheckBoxKeyName,
|
||||
[Parameter(Mandatory)]
|
||||
[double]$ColumnWidth,
|
||||
[string]$IsSelectedPropertyName = "IsSelected"
|
||||
[string]$IsSelectedPropertyName = "IsSelected",
|
||||
[switch]$HeaderSelectionAffectsVisibleItemsOnly
|
||||
)
|
||||
|
||||
# Ensure the ListView has a GridView
|
||||
@@ -342,11 +345,13 @@ 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
|
||||
|
||||
# MODIFICATION: Store the actual ListView object in the header's Tag
|
||||
# Store header metadata, including whether select-all should only affect visible rows.
|
||||
$headerTagObject = [PSCustomObject]@{
|
||||
PropertyName = $IsSelectedPropertyName
|
||||
ListViewControl = $ListView
|
||||
HeaderSelectionAffectsVisibleItemsOnly = [bool]$HeaderSelectionAffectsVisibleItemsOnly
|
||||
}
|
||||
$headerCheckBox.Tag = $headerTagObject
|
||||
|
||||
@@ -356,8 +361,24 @@ function Add-SelectableGridViewColumn {
|
||||
$localPropertyName = $tagData.PropertyName
|
||||
$actualListView = $tagData.ListViewControl
|
||||
|
||||
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||
if ($null -ne $collectionToUpdate) {
|
||||
# Select either visible view items only (filtered scope) or the full backing list.
|
||||
$collectionToUpdate = @()
|
||||
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
|
||||
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($visibleItem in $collectionView) {
|
||||
$collectionToUpdate += $visibleItem
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $actualListView.ItemsSource) {
|
||||
$collectionToUpdate = @($actualListView.ItemsSource)
|
||||
}
|
||||
elseif ($actualListView.HasItems) {
|
||||
$collectionToUpdate = @($actualListView.Items)
|
||||
}
|
||||
|
||||
if ($collectionToUpdate.Count -gt 0) {
|
||||
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
|
||||
$actualListView.Items.Refresh()
|
||||
}
|
||||
@@ -370,19 +391,57 @@ function Add-SelectableGridViewColumn {
|
||||
$localPropertyName = $tagData.PropertyName
|
||||
$actualListView = $tagData.ListViewControl
|
||||
|
||||
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
|
||||
if ($null -ne $collectionToUpdate) {
|
||||
# Clear either visible view items only (filtered scope) or the full backing list.
|
||||
$collectionToUpdate = @()
|
||||
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
|
||||
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($visibleItem in $collectionView) {
|
||||
$collectionToUpdate += $visibleItem
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $actualListView.ItemsSource) {
|
||||
$collectionToUpdate = @($actualListView.ItemsSource)
|
||||
}
|
||||
elseif ($actualListView.HasItems) {
|
||||
$collectionToUpdate = @($actualListView.Items)
|
||||
}
|
||||
|
||||
if ($collectionToUpdate.Count -gt 0) {
|
||||
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
|
||||
$actualListView.Items.Refresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# 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 = $headerCheckBox
|
||||
$selectableColumn.Header = $selectableHeader
|
||||
$selectableColumn.Width = $ColumnWidth
|
||||
|
||||
$cellTemplate = New-Object System.Windows.DataTemplate
|
||||
@@ -437,6 +496,209 @@ function Add-SelectableGridViewColumn {
|
||||
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
|
||||
}
|
||||
|
||||
# Function to request a deferred GridView column auto-size pass
|
||||
function Request-ListViewColumnAutoResize {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView
|
||||
)
|
||||
|
||||
# Skip startup calls until the visual tree has finished loading.
|
||||
if (-not $ListView.IsLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
# Ensure the ListView has registered auto-resize metadata before scheduling work.
|
||||
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
|
||||
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
|
||||
if ($autoResizeState.ResizePending) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState.ResizePending = $true
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
|
||||
try {
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$gridView = [System.Windows.Controls.GridView]$ListView.View
|
||||
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
|
||||
$visibleItems = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
if ($null -ne $ListView.ItemsSource) {
|
||||
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($visibleItem in $collectionView) {
|
||||
$visibleItems.Add($visibleItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($visibleItem in $ListView.Items) {
|
||||
$visibleItems.Add($visibleItem)
|
||||
}
|
||||
}
|
||||
|
||||
$ListView.UpdateLayout()
|
||||
|
||||
$columnIndex = 0
|
||||
foreach ($column in $gridView.Columns) {
|
||||
if ($fixedColumnIndexes -contains $columnIndex) {
|
||||
$columnIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
if ($null -eq $column) {
|
||||
$columnIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
$headerText = ""
|
||||
$propertyName = $null
|
||||
|
||||
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
|
||||
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
|
||||
}
|
||||
|
||||
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
|
||||
$headerText = [string]$column.Header.Content
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
|
||||
$propertyName = [string]$column.Header.Tag
|
||||
}
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
|
||||
$headerText = [string]$column.Header
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($headerText)) {
|
||||
$headerText = $propertyName
|
||||
}
|
||||
|
||||
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
|
||||
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
|
||||
$headerMeasureBlock.FontFamily = $ListView.FontFamily
|
||||
$headerMeasureBlock.FontSize = $ListView.FontSize
|
||||
$headerMeasureBlock.FontStyle = $ListView.FontStyle
|
||||
$headerMeasureBlock.FontWeight = $ListView.FontWeight
|
||||
$headerMeasureBlock.FontStretch = $ListView.FontStretch
|
||||
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
|
||||
|
||||
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
|
||||
|
||||
foreach ($item in $visibleItems) {
|
||||
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$itemProperty = $null
|
||||
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
|
||||
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
|
||||
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
|
||||
$itemProperty = $matchedProperties | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $itemProperty) {
|
||||
continue
|
||||
}
|
||||
|
||||
$itemText = [string]$itemProperty.Value
|
||||
$extraWidth = 28
|
||||
|
||||
switch ($propertyName) {
|
||||
'Architecture' {
|
||||
$extraWidth = 52
|
||||
}
|
||||
'AdditionalExitCodes' {
|
||||
$extraWidth = 44
|
||||
}
|
||||
'IgnoreNonZeroExitCodes' {
|
||||
$itemText = ' '
|
||||
$extraWidth = 48
|
||||
}
|
||||
'IgnoreExitCodes' {
|
||||
$extraWidth = 28
|
||||
}
|
||||
}
|
||||
|
||||
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
|
||||
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
|
||||
$itemMeasureBlock.FontFamily = $ListView.FontFamily
|
||||
$itemMeasureBlock.FontSize = $ListView.FontSize
|
||||
$itemMeasureBlock.FontStyle = $ListView.FontStyle
|
||||
$itemMeasureBlock.FontWeight = $ListView.FontWeight
|
||||
$itemMeasureBlock.FontStretch = $ListView.FontStretch
|
||||
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
|
||||
|
||||
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
|
||||
if ($itemWidth -gt $calculatedWidth) {
|
||||
$calculatedWidth = $itemWidth
|
||||
}
|
||||
}
|
||||
|
||||
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
|
||||
$calculatedWidth = [math]::Max($calculatedWidth, 120)
|
||||
}
|
||||
|
||||
$column.Width = [math]::Max($calculatedWidth, 40)
|
||||
$columnIndex++
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
|
||||
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
|
||||
WriteLog $_.InvocationInfo.PositionMessage
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
|
||||
WriteLog $_.ScriptStackTrace
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
$autoResizeState.ResizePending = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Function to enable reusable auto-resizing for GridView-backed ListViews
|
||||
function Enable-ListViewColumnAutoResize {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[System.Windows.Controls.ListView]$ListView,
|
||||
|
||||
[int[]]$FixedColumnIndexes = @()
|
||||
)
|
||||
|
||||
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
|
||||
|
||||
# Only GridView-backed lists can participate in column auto-sizing.
|
||||
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
|
||||
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
|
||||
return
|
||||
}
|
||||
|
||||
if ($ListView.Resources.Contains($autoResizeStateKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
$autoResizeState = [PSCustomObject]@{
|
||||
FixedColumnIndexes = @($FixedColumnIndexes)
|
||||
ResizePending = $false
|
||||
}
|
||||
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
|
||||
}
|
||||
|
||||
# Function to update the IsChecked state of a "Select All" header CheckBox
|
||||
function Update-SelectAllHeaderCheckBoxState {
|
||||
param(
|
||||
@@ -446,24 +708,38 @@ function Update-SelectAllHeaderCheckBoxState {
|
||||
[System.Windows.Controls.CheckBox]$HeaderCheckBox
|
||||
)
|
||||
|
||||
$collectionToInspect = $null
|
||||
if ($null -ne $ListView.ItemsSource) {
|
||||
# Determine whether this header should evaluate only visible (filtered) rows.
|
||||
$inspectVisibleItemsOnly = $false
|
||||
if ($null -ne $HeaderCheckBox.Tag -and $null -ne $HeaderCheckBox.Tag.PSObject.Properties['HeaderSelectionAffectsVisibleItemsOnly']) {
|
||||
$inspectVisibleItemsOnly = [bool]$HeaderCheckBox.Tag.HeaderSelectionAffectsVisibleItemsOnly
|
||||
}
|
||||
|
||||
# Build the collection to inspect based on scope (visible view vs full source).
|
||||
$collectionToInspect = @()
|
||||
if ($inspectVisibleItemsOnly -and $null -ne $ListView.ItemsSource) {
|
||||
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
|
||||
if ($null -ne $collectionView) {
|
||||
foreach ($visibleItem in $collectionView) {
|
||||
$collectionToInspect += $visibleItem
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $ListView.ItemsSource) {
|
||||
$collectionToInspect = @($ListView.ItemsSource)
|
||||
}
|
||||
elseif ($ListView.HasItems) {
|
||||
# Check if Items collection has items and ItemsSource is null
|
||||
$collectionToInspect = @($ListView.Items)
|
||||
}
|
||||
|
||||
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty)
|
||||
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) {
|
||||
# If no items are available in the selected scope, force unchecked.
|
||||
if ($collectionToInspect.Count -eq 0) {
|
||||
$HeaderCheckBox.IsChecked = $false
|
||||
return
|
||||
}
|
||||
|
||||
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
|
||||
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
|
||||
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected
|
||||
$totalItemCount = $collectionToInspect.Count
|
||||
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
|
||||
|
||||
if ($totalItemCount -eq 0) {
|
||||
@@ -502,6 +778,7 @@ function Invoke-ListViewItemToggle {
|
||||
# Toggle the IsSelected property
|
||||
$selectedItem.IsSelected = -not $selectedItem.IsSelected
|
||||
$ListView.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $ListView
|
||||
|
||||
# Update the 'Select All' header checkbox state
|
||||
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
|
||||
@@ -672,6 +949,8 @@ function Invoke-ListViewSort {
|
||||
$newView.Filter = $existingFilter
|
||||
}
|
||||
}
|
||||
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -894,6 +1173,9 @@ function Invoke-BrowseAction {
|
||||
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
|
||||
$dialog.InitialDirectory = $InitialDirectory
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
|
||||
$dialog.FileName = $FileName
|
||||
}
|
||||
if ($dialog.ShowDialog()) {
|
||||
return $dialog.FileName
|
||||
}
|
||||
@@ -972,6 +1254,7 @@ function Clear-ListViewContent {
|
||||
}
|
||||
|
||||
$ListViewControl.Items.Refresh()
|
||||
Request-ListViewColumnAutoResize -ListView $ListViewControl
|
||||
|
||||
# Clear any specified textboxes
|
||||
if ($null -ne $TextBoxesToClear) {
|
||||
|
||||
@@ -59,6 +59,7 @@ function Search-WingetApps {
|
||||
|
||||
# Update the ListView's ItemsSource using the passed-in State object
|
||||
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
|
||||
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
|
||||
|
||||
# Update status text
|
||||
$statusText = ""
|
||||
@@ -108,20 +109,28 @@ 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 App List"
|
||||
# Correctly get the path from the UI control via the State object
|
||||
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text
|
||||
$sfd.FileName = "AppList.json"
|
||||
$sfd.Title = "Save Winget App List"
|
||||
$sfd.InitialDirectory = $initialDirectory
|
||||
$sfd.FileName = $fileName
|
||||
|
||||
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information")
|
||||
$State.Controls.txtAppListJsonPath.Text = $sfd.FileName
|
||||
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error")
|
||||
[System.Windows.MessageBox]::Show("Error saving Winget app list: $_", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +141,17 @@ 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 App List"
|
||||
# Correctly get the path from the UI control via the State object
|
||||
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
|
||||
$ofd.Title = "Import Winget App List"
|
||||
$ofd.InitialDirectory = $initialDirectory
|
||||
|
||||
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
|
||||
@@ -144,16 +159,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 # Imported apps are marked as selected
|
||||
IsSelected = $true
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = "" # Will be populated when searching or if data exists
|
||||
Version = ""
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
|
||||
@@ -164,12 +179,14 @@ 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("App list imported successfully.", "Success", "OK", "Information")
|
||||
[System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
catch {
|
||||
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
|
||||
[System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,16 +106,20 @@ 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
|
||||
@@ -123,7 +127,6 @@ function Get-GeneralDefaults {
|
||||
CompactOS = $true
|
||||
Optimize = $true
|
||||
AllowVHDXCaching = $false
|
||||
CreateCaptureMedia = $true
|
||||
CreateDeploymentMedia = $true
|
||||
Verbose = $false
|
||||
AllowExternalHardDiskMedia = $false
|
||||
@@ -134,15 +137,23 @@ 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
|
||||
RemoveApps = $false
|
||||
RemoveUpdates = $false
|
||||
RemoveDownloadedESD = $true
|
||||
# Hyper-V Settings Defaults
|
||||
VMHostIPAddress = ""
|
||||
EnableVMNetworking = $false
|
||||
DiskSizeGB = 50
|
||||
MemoryGB = 4
|
||||
Processors = 4
|
||||
@@ -162,6 +173,7 @@ function Get-GeneralDefaults {
|
||||
InstallApps = $false
|
||||
ApplicationPath = $appsPath
|
||||
AppListJsonPath = $appListJsonPath
|
||||
UserAppListPath = $userAppListPath
|
||||
InstallWingetApps = $false
|
||||
BringYourOwnApps = $false
|
||||
# M365 Apps/Office Tab Defaults
|
||||
@@ -265,6 +277,7 @@ function Update-AdditionalFFUList {
|
||||
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
|
||||
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
|
||||
}
|
||||
Request-ListViewColumnAutoResize -ListView $listView
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
@@ -304,6 +317,7 @@ 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
|
||||
@@ -320,6 +334,23 @@ function Update-ApplicationPanelVisibility {
|
||||
}
|
||||
}
|
||||
|
||||
# Function to identify whether current Windows release selection is Windows 10 LTSB/LTSC
|
||||
function Test-IsWindows10LtscReleaseSelection {
|
||||
param([PSCustomObject]$State)
|
||||
|
||||
$releaseItem = $State.Controls.cmbWindowsRelease.SelectedItem
|
||||
if ($null -eq $releaseItem) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$releaseDisplay = [string]$releaseItem.Display
|
||||
if ([string]::IsNullOrWhiteSpace($releaseDisplay)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return (($releaseDisplay -like 'Windows 10*') -and (($releaseDisplay -like '*LTSB*') -or ($releaseDisplay -like '*LTSC*')))
|
||||
}
|
||||
|
||||
# Function to manage the state of the main "Install Apps" checkbox based on selections in Updates/Office
|
||||
function Update-InstallAppsState {
|
||||
param([PSCustomObject]$State)
|
||||
@@ -327,11 +358,16 @@ function Update-InstallAppsState {
|
||||
$installAppsChk = $State.Controls.chkInstallApps
|
||||
$installOfficeChk = $State.Controls.chkInstallOffice
|
||||
|
||||
# Determine if Windows 10 LTSB/LTSC + Update Latest CU is selected
|
||||
$isWindows10LtscRelease = Test-IsWindows10LtscReleaseSelection -State $State
|
||||
$isLtscCuChecked = $State.Controls.chkUpdateLatestCU.IsChecked -and $isWindows10LtscRelease
|
||||
|
||||
# Determine if any checkbox that forces "Install Apps" is checked
|
||||
$anyUpdateChecked = $State.Controls.chkUpdateLatestDefender.IsChecked -or `
|
||||
$State.Controls.chkUpdateEdge.IsChecked -or `
|
||||
$State.Controls.chkUpdateOneDrive.IsChecked -or `
|
||||
$State.Controls.chkUpdateLatestMSRT.IsChecked
|
||||
$State.Controls.chkUpdateLatestMSRT.IsChecked -or `
|
||||
$isLtscCuChecked
|
||||
|
||||
$isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked
|
||||
|
||||
@@ -445,6 +481,777 @@ 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
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
select disk 0
|
||||
select partition 3
|
||||
Assign letter="M"
|
||||
exit
|
||||
@@ -1,237 +0,0 @@
|
||||
$VMHostIPAddress = '192.168.1.158'
|
||||
$ShareName = 'FFUCaptureShare'
|
||||
$UserName = 'ffu_user'
|
||||
$Password = '23202eb4-10c3-47e9-b389-f0c462663a23'
|
||||
$CustomFFUNameTemplate = '{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}'
|
||||
|
||||
$netuseCommand = "net use W: \\$VMHostIPAddress\$ShareName /user:$UserName $Password 2>&1"
|
||||
|
||||
# Connect to network share
|
||||
try {
|
||||
Write-Host "Connecting to network share via $netuseCommand"
|
||||
$netUseResult = net use W: "\\$VMHostIPAddress\$ShareName" "/user:$UserName" "$Password" 2>&1
|
||||
|
||||
# Check if the result contains an error
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
# Extract the error code from the Exception Message
|
||||
# Example message format: "System error 53 has occurred."
|
||||
$message = $netUseResult.Exception.Message
|
||||
$regex = [regex]'System error (\d+)'
|
||||
$match = $regex.Match($message)
|
||||
if ($match.Success) {
|
||||
$errorCode = [int]$match.Groups[1].Value
|
||||
|
||||
$errorMessage = switch ($errorCode) {
|
||||
53 { "Network path not found. Verify the IP address is correct and the server is accessible." }
|
||||
67 { "Network name cannot be found. Verify the share name exists on the server." }
|
||||
86 { "Password is incorrect for the specified username." }
|
||||
1219 { "Multiple connections to the share exist."}
|
||||
1326 { "Logon failure: unknown username or bad password." }
|
||||
1385 { "Logon failure: the user has not been granted the requested logon type at this computer.
|
||||
This is likely due to changes to the User Rights Assignment: Access this computer from the network local security policy
|
||||
See: https://github.com/rbalsleyMSFT/FFU/issues/122 for more info" }
|
||||
1792 { "Unable to connect. Verify the server is running and accepting connections." }
|
||||
2250 { "Network connection attempt timed out." }
|
||||
default { "Network connection failed with error code: $errorCode. Details: $message" }
|
||||
}
|
||||
# Write-Error $errorMessage
|
||||
throw $errorMessage
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error "Failed to connect to network share: Error code: $errorcode $_"
|
||||
Write-Host "Some things to try:"
|
||||
Write-Host '1. If not using an external switch, change to using an external switch'
|
||||
Write-Host '2. Make sure the VMHostIPAddress is correct for the VMSwitch that is being used'
|
||||
Write-Host '3. Try disabling the Windows Firewall on the host machine as a test only. If that helps, there is a Windows firewall rule that is blocking SMB 445 into the VM host'
|
||||
Write-Host '4. If this is a machine that is managed by your organization, try using another machine that is not managed. There could be security policies in place that are blocking the connection to the share.'
|
||||
Write-Host '5. You can also try disabling Hyper-V and re-enabling it. This has helped some users in the past.'
|
||||
Write-Host '6. If all else fails, open an issue on the github repo and attach screenshots of this message, your FFUDevelopment.log, your command line that you used to build the FFU, and/or the config file you used (if you used one).'
|
||||
pause
|
||||
throw
|
||||
}
|
||||
|
||||
$AssignDriveLetter = 'x:\AssignDriveLetter.txt'
|
||||
try {
|
||||
Write-Host 'Assigning M: as Windows drive letter'
|
||||
Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to assign drive letter using diskpart: $_"
|
||||
|
||||
}
|
||||
|
||||
#Load Registry Hive
|
||||
$Software = 'M:\Windows\System32\config\software'
|
||||
try {
|
||||
Write-Host "Loading software registry hive to $Software"
|
||||
if (-not (Test-Path -Path $Software)) {
|
||||
throw "Software registry hive not found at $Software"
|
||||
}
|
||||
$regResult = reg load "HKLM\FFU" $Software 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
|
||||
}
|
||||
Write-Host "Successfully loaded software registry hive."
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to load registry hive: $_"
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
#Find Windows version values
|
||||
Write-Host "Retrieving Windows information from the registry..."
|
||||
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
|
||||
Write-Host "SKU: $SKU"
|
||||
[int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
|
||||
Write-Host "CurrentBuild: $CurrentBuild"
|
||||
if ($CurrentBuild -notin 14393, 17763) {
|
||||
Write-Host "CurrentBuild is not 14393 or 17763, retrieving WindowsVersion..."
|
||||
$WindowsVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
|
||||
Write-Host "WindowsVersion: $WindowsVersion"
|
||||
}
|
||||
$InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType'
|
||||
Write-Host "InstallationType: $InstallationType"
|
||||
$BuildDate = Get-Date -uformat %b%Y
|
||||
Write-Host "BuildDate: $BuildDate"
|
||||
|
||||
$SKU = switch ($SKU) {
|
||||
Core { 'Home' }
|
||||
CoreN { 'Home_N' }
|
||||
CoreSingleLanguage { 'Home_SL' }
|
||||
Professional { 'Pro' }
|
||||
ProfessionalN { 'Pro_N' }
|
||||
ProfessionalEducation { 'Pro_Edu' }
|
||||
ProfessionalEducationN { 'Pro_Edu_N' }
|
||||
Enterprise { 'Ent' }
|
||||
EnterpriseN { 'Ent_N' }
|
||||
EnterpriseS { 'Ent_LTSC' }
|
||||
EnterpriseSN { 'Ent_N_LTSC' }
|
||||
IoTEnterpriseS { 'IoT_Ent_LTSC' }
|
||||
Education { 'Edu' }
|
||||
EducationN { 'Edu_N' }
|
||||
ProfessionalWorkstation { 'Pro_Wks' }
|
||||
ProfessionalWorkstationN { 'Pro_Wks_N' }
|
||||
ServerStandard { 'Srv_Std' }
|
||||
ServerDatacenter { 'Srv_Dtc' }
|
||||
}
|
||||
|
||||
if ($InstallationType -eq "Client") {
|
||||
if ($CurrentBuild -ge 22000) {
|
||||
$WindowsRelease = 'Win11'
|
||||
Write-Host "WindowsRelease: $WindowsRelease"
|
||||
}
|
||||
else {
|
||||
$WindowsRelease = 'Win10'
|
||||
Write-Host "WindowsRelease: $WindowsRelease"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$WindowsRelease = switch ($CurrentBuild) {
|
||||
26100 { '2025' }
|
||||
20348 { '2022' }
|
||||
17763 { '2019' }
|
||||
14393 { '2016' }
|
||||
Default { $WindowsVersion }
|
||||
}
|
||||
Write-Host "WindowsRelease: $WindowsRelease"
|
||||
if ($InstallationType -eq "Server Core") {
|
||||
$SKU += "_Core"
|
||||
Write-Host "InstallType is Server Core, changing SKU to: $SKU"
|
||||
}
|
||||
}
|
||||
|
||||
if ($CustomFFUNameTemplate) {
|
||||
Write-Host 'Using custom FFU name template...'
|
||||
$FFUFileName = $CustomFFUNameTemplate
|
||||
$FFUFileName = $FFUFileName -replace '{WindowsRelease}', $WindowsRelease
|
||||
$FFUFileName = $FFUFileName -replace '{WindowsVersion}', $WindowsVersion
|
||||
$FFUFileName = $FFUFileName -replace '{SKU}', $SKU
|
||||
$FFUFileName = $FFUFileName -replace '{BuildDate}', $BuildDate
|
||||
$FFUFileName = $FFUFileName -replace '{yyyy}', (Get-Date -UFormat '%Y')
|
||||
$FFUFileName = $FFUFileName -creplace '{MM}', (Get-Date -UFormat '%m')
|
||||
$FFUFileName = $FFUFileName -replace '{dd}', (Get-Date -UFormat '%d')
|
||||
$FFUFileName = $FFUFileName -creplace '{HH}', (Get-Date -UFormat '%H')
|
||||
$FFUFileName = $FFUFileName -creplace '{hh}', (Get-Date -UFormat '%I')
|
||||
$FFUFileName = $FFUFileName -creplace '{mm}', (Get-Date -UFormat '%M')
|
||||
$FFUFileName = $FFUFileName -replace '{tt}', (Get-Date -UFormat '%p')
|
||||
Write-Host "FFU File Name: $FFUFileName"
|
||||
#If the custom FFU name template does not end with .ffu, append it
|
||||
if ($FFUFileName -notlike '*.ffu') {
|
||||
$FFUFileName += '.ffu'
|
||||
Write-Host "Appended .ffu to FFU file name: $FFUFileName"
|
||||
}
|
||||
$dismArgs = "/capture-ffu /imagefile=W:\$FFUFileName /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
|
||||
Write-Host "DISM arguments for capture: $dismArgs"
|
||||
}
|
||||
else {
|
||||
#If Office is installed, modify the file name of the FFU
|
||||
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
|
||||
if ($Office) {
|
||||
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
|
||||
Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
|
||||
}
|
||||
else {
|
||||
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu"
|
||||
Write-Host "Office is not installed, using modified FFU file name: $ffuFilePath"
|
||||
}
|
||||
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
|
||||
Write-Host "DISM arguments for capture: $dismArgs"
|
||||
}
|
||||
|
||||
#Unload Registry
|
||||
Set-Location X:\
|
||||
Remove-Variable SKU
|
||||
Remove-Variable CurrentBuild
|
||||
if ($CurrentBuild -notin 14393, 17763) {
|
||||
Remove-Variable WindowsVersion
|
||||
}
|
||||
if ($Office) {
|
||||
Remove-Variable Office
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "Unloading registry hive HKLM\FFU..."
|
||||
$regUnloadResult = reg unload "HKLM\FFU" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
|
||||
}
|
||||
Write-Host "Successfully unloaded registry hive."
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to unload registry hive: $_"
|
||||
|
||||
}
|
||||
|
||||
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
|
||||
Start-sleep 60
|
||||
|
||||
try {
|
||||
Write-Host "Starting DISM FFU capture..."
|
||||
$dismProcess = Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop
|
||||
if ($dismProcess.ExitCode -ne 0) {
|
||||
throw "DISM capture failed with exit code $($dismProcess.ExitCode)"
|
||||
}
|
||||
Write-Host "DISM FFU capture completed successfully."
|
||||
}
|
||||
catch {
|
||||
Write-Error "FFU capture failed: $_"
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "Copying DISM log to network share..."
|
||||
xcopy X:\Windows\logs\dism\dism.log W:\ /Y | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to copy DISM log: $_"
|
||||
}
|
||||
Write-Host "DISM log copied to network share, shutting down..."
|
||||
wpeutil Shutdown
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "An unexpected error occurred: $_"
|
||||
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
wpeinit
|
||||
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
||||
powershell -Noprofile -ExecutionPolicy Bypass -File x:\CaptureFFU.ps1
|
||||
exit
|
||||
|
||||
@@ -41,6 +41,59 @@ function WriteLog($LogText) {
|
||||
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText"
|
||||
}
|
||||
|
||||
function Read-MenuSelection {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Prompt,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InvalidInputMessage,
|
||||
|
||||
[Parameter()]
|
||||
[int[]]$ValidSelections,
|
||||
|
||||
[Parameter()]
|
||||
[int]$Minimum = [int]::MinValue,
|
||||
|
||||
[Parameter()]
|
||||
[int]$Maximum = [int]::MaxValue,
|
||||
|
||||
[Parameter()]
|
||||
[switch]$AllowSkip
|
||||
)
|
||||
|
||||
do {
|
||||
$userInput = Read-Host $Prompt
|
||||
if ([string]::IsNullOrWhiteSpace($userInput)) {
|
||||
Write-Host $InvalidInputMessage
|
||||
continue
|
||||
}
|
||||
|
||||
$selection = 0
|
||||
if (-not [int]::TryParse($userInput, [ref]$selection)) {
|
||||
Write-Host $InvalidInputMessage
|
||||
continue
|
||||
}
|
||||
|
||||
if ($AllowSkip -and $selection -eq 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('ValidSelections')) {
|
||||
if ($ValidSelections -notcontains $selection) {
|
||||
Write-Host $InvalidInputMessage
|
||||
continue
|
||||
}
|
||||
}
|
||||
elseif ($selection -lt $Minimum -or $selection -gt $Maximum) {
|
||||
Write-Host $InvalidInputMessage
|
||||
continue
|
||||
}
|
||||
|
||||
return $selection
|
||||
} until ($false)
|
||||
}
|
||||
|
||||
function Set-DiskpartAnswerFiles($DiskpartFile, $DiskID) {
|
||||
(Get-Content $DiskpartFile).Replace('disk 0', "disk $DiskID") | Set-Content -Path $DiskpartFile
|
||||
}
|
||||
@@ -64,6 +117,68 @@ function Set-Computername($computername) {
|
||||
return $computername
|
||||
}
|
||||
|
||||
function Get-UnattendComputerNameValue {
|
||||
if ($null -eq $UnattendFile) {
|
||||
return $null
|
||||
}
|
||||
|
||||
[xml]$xml = Get-Content $UnattendFile
|
||||
foreach ($component in $xml.unattend.settings.component) {
|
||||
if ($component.ComputerName) {
|
||||
return [string]$component.ComputerName
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-LegacyPromptComputerName($computername) {
|
||||
if ([string]::IsNullOrWhiteSpace($computername)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$normalizedName = $computername.Trim().ToLowerInvariant()
|
||||
return $normalizedName -in @('mycomputer', 'default')
|
||||
}
|
||||
|
||||
function Get-NormalizedComputerName($computername) {
|
||||
if ([string]::IsNullOrWhiteSpace($computername)) {
|
||||
throw 'Computer name cannot be empty.'
|
||||
}
|
||||
|
||||
$normalizedName = ($computername -replace "\s", '').Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
||||
throw 'Computer name cannot be empty after removing spaces.'
|
||||
}
|
||||
|
||||
if ($normalizedName.Length -gt 15) {
|
||||
$normalizedName = $normalizedName.Substring(0, 15)
|
||||
}
|
||||
|
||||
return $normalizedName
|
||||
}
|
||||
|
||||
function Resolve-ComputerNameTemplate($computerNameTemplate, $serialNumber) {
|
||||
if ([string]::IsNullOrWhiteSpace($computerNameTemplate)) {
|
||||
throw 'Computer name template cannot be empty.'
|
||||
}
|
||||
|
||||
$resolvedName = $computerNameTemplate -replace '(?i)%serial%', $serialNumber
|
||||
if ($resolvedName -match '%') {
|
||||
throw 'Unsupported device name variable found. Only %serial% is supported.'
|
||||
}
|
||||
|
||||
return Get-NormalizedComputerName($resolvedName)
|
||||
}
|
||||
|
||||
function Set-ConfiguredComputerName($computername) {
|
||||
$normalizedName = Get-NormalizedComputerName($computername)
|
||||
$normalizedName = Set-Computername($normalizedName)
|
||||
Writelog "Computer name will be set to $normalizedName"
|
||||
Write-Host "Computer name will be set to $normalizedName"
|
||||
return $normalizedName
|
||||
}
|
||||
|
||||
function Invoke-Process {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param
|
||||
@@ -835,7 +950,7 @@ $LogFileName = 'ScriptLog.txt'
|
||||
$USBDrive = Get-USBDrive
|
||||
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
|
||||
$LogFile = $USBDrive + $LogFilename
|
||||
$version = '2602.1Preview'
|
||||
$version = '2604.1'
|
||||
WriteLog 'Begin Logging'
|
||||
WriteLog "Script version: $version"
|
||||
|
||||
@@ -891,21 +1006,7 @@ else {
|
||||
}
|
||||
$displayList | Format-Table -AutoSize -Property Disk, 'Size (GB)', Sector, 'Bus Type', Model
|
||||
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$diskSelection = Read-Host 'Enter the disk number to apply the FFU to'
|
||||
}
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid disk number'
|
||||
$var = $false
|
||||
}
|
||||
# Validate selected disk is in the list of available disks
|
||||
if ($var -and $validDiskIndexes -notcontains $diskSelection) {
|
||||
Write-Host "Invalid disk number. Please select from the available disks."
|
||||
$var = $false
|
||||
}
|
||||
} until ($var)
|
||||
$diskSelection = Read-MenuSelection -Prompt 'Enter the disk number to apply the FFU to' -InvalidInputMessage 'Invalid disk number. Please select from the available disks.' -ValidSelections $validDiskIndexes
|
||||
|
||||
$selectedDisk = $diskDriveCandidates | Where-Object { $_.Index -eq $diskSelection }
|
||||
WriteLog "Disk selection: DiskNumber=$($selectedDisk.Index), Model=$($selectedDisk.Model), SizeGB=$([math]::Round(($selectedDisk.Size / 1GB), 2)), BusType=$($selectedDisk.InterfaceType)"
|
||||
@@ -949,18 +1050,8 @@ If ($FFUCount -gt 1) {
|
||||
$array += New-Object PSObject -Property $Properties
|
||||
}
|
||||
$array | Format-Table -AutoSize -Property Number, FFUFile | Out-Host
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$FFUSelected = Read-Host 'Enter the FFU number to install'
|
||||
$FFUSelected = Read-MenuSelection -Prompt 'Enter the FFU number to install' -InvalidInputMessage 'Input was not in correct format. Please enter a valid FFU number.' -Minimum 1 -Maximum $FFUCount
|
||||
$FFUSelected = $FFUSelected - 1
|
||||
}
|
||||
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid FFU number'
|
||||
$var = $false
|
||||
}
|
||||
} until (($FFUSelected -le $FFUCount - 1) -and $var)
|
||||
|
||||
$FFUFileToInstall = $array[$FFUSelected].FFUFile
|
||||
WriteLog "$FFUFileToInstall was selected"
|
||||
@@ -1023,13 +1114,26 @@ If (Test-Path -Path $UnattendComputerNamePath) {
|
||||
}
|
||||
}
|
||||
|
||||
#Ask for device name if unattend exists
|
||||
If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
|
||||
$UnattendConfiguredComputerName = $null
|
||||
$RequiresLegacyDeviceNamePrompt = $false
|
||||
$RequiresTemplateDeviceName = $false
|
||||
if ($Unattend) {
|
||||
$UnattendConfiguredComputerName = Get-UnattendComputerNameValue
|
||||
$RequiresLegacyDeviceNamePrompt = Test-LegacyPromptComputerName($UnattendConfiguredComputerName)
|
||||
if (-not [string]::IsNullOrWhiteSpace($UnattendConfiguredComputerName) -and $UnattendConfiguredComputerName -match '(?i)%serial%') {
|
||||
$RequiresTemplateDeviceName = $true
|
||||
}
|
||||
}
|
||||
|
||||
#Ask for device name if naming is explicitly required
|
||||
If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -or $RequiresLegacyDeviceNamePrompt) {
|
||||
Write-SectionHeader 'Device Name Selection'
|
||||
if ($Unattend -and $UnattendPrefix) {
|
||||
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
|
||||
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
|
||||
$UnattendPrefixCount = $UnattendPrefixes.Count
|
||||
$skipPrefixSelection = $false
|
||||
$PrefixToUse = $null
|
||||
If ($UnattendPrefixCount -gt 1) {
|
||||
WriteLog "Found $UnattendPrefixCount Prefixes"
|
||||
$array = @()
|
||||
@@ -1038,21 +1142,19 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
|
||||
$array += New-Object PSObject -Property $Properties
|
||||
}
|
||||
$array | Format-Table -AutoSize -Property Number, DeviceNamePrefix
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$PrefixSelected = Read-Host 'Enter the prefix number to use for the device name'
|
||||
$PrefixSelected = $PrefixSelected - 1
|
||||
$prefixSelection = Read-MenuSelection -Prompt 'Enter the prefix number to use for the device name (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid prefix number.' -Minimum 1 -Maximum $UnattendPrefixCount -AllowSkip
|
||||
if ($prefixSelection -eq 0) {
|
||||
$skipPrefixSelection = $true
|
||||
WriteLog 'User chose to skip device name prefix selection. Existing unattend computer name will remain unchanged.'
|
||||
Write-Host "`nDevice name prefix selection was skipped. The existing unattend computer name will remain unchanged."
|
||||
}
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid prefix number'
|
||||
$var = $false
|
||||
}
|
||||
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
|
||||
else {
|
||||
$PrefixSelected = $prefixSelection - 1
|
||||
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
|
||||
WriteLog "$PrefixToUse was selected"
|
||||
Write-Host "`n$PrefixToUse was selected as device name prefix"
|
||||
}
|
||||
}
|
||||
elseif ($UnattendPrefixCount -eq 1) {
|
||||
WriteLog "Found $UnattendPrefixCount Prefix"
|
||||
Write-Host "Found $UnattendPrefixCount Prefix"
|
||||
@@ -1060,17 +1162,10 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
|
||||
WriteLog "Will use $PrefixToUse as device name prefix"
|
||||
Write-Host "Will use $PrefixToUse as device name prefix"
|
||||
}
|
||||
#Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace
|
||||
if (-not $skipPrefixSelection) {
|
||||
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
|
||||
#Combine prefix with serial
|
||||
$computername = ($PrefixToUse + $serial) -replace "\s", "" # Remove spaces because windows does not support spaces in the computer names
|
||||
#If computername is longer than 15 characters, reduce to 15. Sysprep/unattend doesn't like ComputerName being longer than 15 characters even though Windows accepts it
|
||||
If ($computername.Length -gt 15) {
|
||||
$computername = $computername.substring(0, 15)
|
||||
$computername = Set-ConfiguredComputerName($PrefixToUse + $serial)
|
||||
}
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
}
|
||||
elseif ($Unattend -and $UnattendComputerName) {
|
||||
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
|
||||
@@ -1080,32 +1175,31 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
|
||||
$SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber }
|
||||
|
||||
If ($SCName) {
|
||||
[string]$computername = $SCName.ComputerName
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
[string]$computername = Set-ConfiguredComputerName($SCName.ComputerName)
|
||||
}
|
||||
else {
|
||||
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||
[string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ })))
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
[string]$computername = Set-ConfiguredComputerName(("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ }))))
|
||||
}
|
||||
}
|
||||
elseif ($Unattend) {
|
||||
elseif ($Unattend -and $RequiresTemplateDeviceName) {
|
||||
Writelog 'Unattend file found with a %serial% computer name template. Resolving the template.'
|
||||
$serialNumber = (Get-CimInstance -ClassName Win32_Bios).SerialNumber.Trim()
|
||||
[string]$computername = Set-ConfiguredComputerName((Resolve-ComputerNameTemplate -computerNameTemplate $UnattendConfiguredComputerName -serialNumber $serialNumber))
|
||||
}
|
||||
elseif ($Unattend -and $RequiresLegacyDeviceNamePrompt) {
|
||||
Writelog 'Unattend file found with no prefixes.txt, asking for name'
|
||||
Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
|
||||
[string]$computername = Read-Host 'Enter device name'
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
[string]$computername = Set-ConfiguredComputerName((Read-Host 'Enter device name'))
|
||||
}
|
||||
else {
|
||||
WriteLog 'Device naming assets detected without unattend.xml. Skipping device naming prompts.'
|
||||
}
|
||||
}
|
||||
elseif ($Unattend) {
|
||||
WriteLog 'Unattend file found. Device naming is not required, but unattend settings will still be applied.'
|
||||
}
|
||||
else {
|
||||
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
|
||||
}
|
||||
@@ -1114,17 +1208,7 @@ else {
|
||||
If ($autopilot -eq $true -and $PPKG -eq $true) {
|
||||
WriteLog 'Both PPKG and Autopilot json files found'
|
||||
Write-Host 'Both Autopilot JSON files and Provisioning packages were found.'
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$APorPPKG = Read-Host 'Enter 1 for Autopilot or 2 for Provisioning Package'
|
||||
}
|
||||
|
||||
catch {
|
||||
Write-Host 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package'
|
||||
$var = $false
|
||||
}
|
||||
} until (($APorPPKG -gt 0 -and $APorPPKG -lt 3) -and $var)
|
||||
$APorPPKG = Read-MenuSelection -Prompt 'Enter 1 for Autopilot or 2 for Provisioning Package' -InvalidInputMessage 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package.' -Minimum 1 -Maximum 2
|
||||
If ($APorPPKG -eq 1) {
|
||||
$PPKG = $false
|
||||
}
|
||||
@@ -1143,23 +1227,21 @@ If ($APFilesCount -gt 1 -and $autopilot -eq $true) {
|
||||
$array += New-Object PSObject -Property $Properties
|
||||
}
|
||||
$array | Format-Table -AutoSize -Property Number, APFileName
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$APFileSelected = Read-Host 'Enter the AP json file number to install'
|
||||
$APFileSelected = $APFileSelected - 1
|
||||
}
|
||||
$APFileSelection = Read-MenuSelection -Prompt 'Enter the AP json file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid AP json file number.' -Minimum 1 -Maximum $APFilesCount -AllowSkip
|
||||
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid AP json file number'
|
||||
$var = $false
|
||||
if ($APFileSelection -eq 0) {
|
||||
$APFileToInstall = $null
|
||||
$APFileName = $null
|
||||
WriteLog 'User chose to skip Autopilot JSON selection.'
|
||||
Write-Host "`nAutopilot JSON selection was skipped."
|
||||
}
|
||||
} until (($APFileSelected -le $APFilesCount - 1) -and $var)
|
||||
|
||||
else {
|
||||
$APFileSelected = $APFileSelection - 1
|
||||
$APFileToInstall = $array[$APFileSelected].APFile
|
||||
$APFileName = $array[$APFileSelected].APFileName
|
||||
WriteLog "$APFileToInstall was selected"
|
||||
}
|
||||
}
|
||||
elseif ($APFilesCount -eq 1 -and $autopilot -eq $true) {
|
||||
WriteLog "Found $APFilesCount AP File"
|
||||
$APFileToInstall = $APFiles[0].FullName
|
||||
@@ -1181,23 +1263,20 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
|
||||
$array += New-Object PSObject -Property $Properties
|
||||
}
|
||||
$array | Format-Table -AutoSize -Property Number, PPKGFileName
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$PPKGFileSelected = Read-Host 'Enter the PPKG file number to install'
|
||||
$PPKGFileSelected = $PPKGFileSelected - 1
|
||||
}
|
||||
$PPKGFileSelection = Read-MenuSelection -Prompt 'Enter the PPKG file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid PPKG file number.' -Minimum 1 -Maximum $PPKGFilesCount -AllowSkip
|
||||
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid PPKG file number'
|
||||
$var = $false
|
||||
if ($PPKGFileSelection -eq 0) {
|
||||
$PPKGFileToInstall = $null
|
||||
WriteLog 'User chose to skip Provisioning Package selection.'
|
||||
Write-Host "`nProvisioning Package selection was skipped."
|
||||
}
|
||||
} until (($PPKGFileSelected -le $PPKGFilesCount - 1) -and $var)
|
||||
|
||||
else {
|
||||
$PPKGFileSelected = $PPKGFileSelection - 1
|
||||
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
|
||||
WriteLog "$PPKGFileToInstall was selected"
|
||||
Write-Host "`n$PPKGFileToInstall will be used"
|
||||
}
|
||||
}
|
||||
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
|
||||
Write-SectionHeader -Title 'Provisioning Package Selection'
|
||||
WriteLog "Found $PPKGFilesCount PPKG File"
|
||||
@@ -1312,7 +1391,7 @@ if ($null -eq $DriverSourcePath) {
|
||||
if ([string]::IsNullOrWhiteSpace($relativeSegment)) {
|
||||
return Split-Path -Path $normalizedPath -Leaf
|
||||
}
|
||||
return $relativePath = $relativeSegment
|
||||
return $relativeSegment
|
||||
}
|
||||
return $normalizedPath
|
||||
}
|
||||
@@ -1391,21 +1470,13 @@ if ($null -eq $DriverSourcePath) {
|
||||
|
||||
$DriverSelected = -1
|
||||
$skipDriverInstall = $false
|
||||
do {
|
||||
try {
|
||||
$var = $true
|
||||
[int]$userSelection = Read-Host 'Enter the number of the driver source to install (0 to skip)'
|
||||
$userSelection = Read-MenuSelection -Prompt 'Enter the number of the driver source to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid number.' -Minimum 1 -Maximum $DriverSourcesCount -AllowSkip
|
||||
if ($userSelection -eq 0) {
|
||||
$skipDriverInstall = $true
|
||||
break
|
||||
}
|
||||
else {
|
||||
$DriverSelected = $userSelection - 1
|
||||
}
|
||||
catch {
|
||||
Write-Host 'Input was not in correct format. Please enter a valid number.'
|
||||
$var = $false
|
||||
}
|
||||
} until ((($DriverSelected -ge 0 -and $DriverSelected -lt $DriverSourcesCount) -or $skipDriverInstall) -and $var)
|
||||
|
||||
if ($skipDriverInstall) {
|
||||
$DriverSourcePath = $null
|
||||
@@ -1568,8 +1639,9 @@ If ($PPKGFileToInstall) {
|
||||
}
|
||||
}
|
||||
#Set DeviceName
|
||||
If ($computername) {
|
||||
Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration'
|
||||
If ($Unattend) {
|
||||
$unattendSectionTitle = if ($computername) { 'Applying Computer Name and Unattend Configuration' } else { 'Applying Unattend Configuration' }
|
||||
Write-SectionHeader -Title $unattendSectionTitle
|
||||
try {
|
||||
$PantherDir = 'w:\windows\panther'
|
||||
If (Test-Path -Path $PantherDir) {
|
||||
@@ -1590,8 +1662,8 @@ If ($computername) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Copying Unattend.xml to name device failed"
|
||||
Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_"
|
||||
WriteLog 'Copying Unattend.xml to Panther failed'
|
||||
Stop-Script -Message "Copying Unattend.xml to Panther failed with error: $_"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
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>MYCOMPUTER</ComputerName><!--Leave Default will be renamed-->
|
||||
<ComputerName>*</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>MyComputer</ComputerName>
|
||||
<ComputerName>*</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>MyComputer</ComputerName>
|
||||
<ComputerName>*</ComputerName>
|
||||
</component>
|
||||
<!--Place addtional Components Elements and settings below here. -->
|
||||
</settings>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# 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.
|
||||
|
||||
# Using Full Flash Update (FFU) files to speed up Windows deployment
|
||||
|
||||
What if you could have a Windows image (Windows 10/11/Server/LTSC) that has:
|
||||
@@ -18,88 +24,14 @@ And the best part: **it takes less than two minutes** to apply the image, even w
|
||||
|
||||
The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
|
||||
|
||||
# Updates
|
||||
|
||||
2509.1 has been released to preview! This is a major update that brings a new user interface to preview.
|
||||
|
||||
Docs are coming, but will take a bit to write them. The youtube video is a must watch for a complete demo on how to use the UI and the changes made to apps (InstallAppsAndSysprep.cmd is gone) and drivers. I'll be recording a more formalized deep dive with slides that go a bit deeper into how things work, but the UI walkthrough should get most people going.
|
||||
|
||||
# Getting Started
|
||||
|
||||
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases)
|
||||
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
|
||||
- Watch the Youtube video (updated docs for the UI coming soon)
|
||||
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).
|
||||
|
||||
## YouTube Detailed Walkthrough
|
||||
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.
|
||||
|
||||
Here's a detailed overview of the new UI process.
|
||||
## Older Youtube Videos
|
||||
|
||||
[](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview")
|
||||
[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.
|
||||
|
||||
Chapters:
|
||||
|
||||
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
|
||||
|
||||
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
|
||||
|
||||
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
|
||||
|
||||
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
|
||||
|
||||
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
|
||||
|
||||
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
|
||||
|
||||
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
|
||||
|
||||
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
|
||||
|
||||
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
|
||||
|
||||
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
|
||||
|
||||
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
|
||||
|
||||
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
|
||||
|
||||
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
|
||||
|
||||
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
|
||||
|
||||
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
|
||||
|
||||
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
|
||||
|
||||
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
|
||||
|
||||
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
|
||||
|
||||
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
|
||||
|
||||
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
|
||||
|
||||
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
|
||||
|
||||
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
|
||||
|
||||
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
|
||||
|
||||
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
|
||||
|
||||
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
|
||||
|
||||
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
|
||||
|
||||
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
|
||||
|
||||
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
|
||||
|
||||
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
|
||||
|
||||
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
|
||||
|
||||
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
|
||||
|
||||
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
|
||||
|
||||
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
|
||||
[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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ remote_theme: just-the-docs/just-the-docs@v0.10.1
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-seo-tag
|
||||
- jekyll-sitemap
|
||||
|
||||
search_enabled: true
|
||||
|
||||
# Canonical host for absolute URLs (sitemap/robots/canonical tags)
|
||||
url: "https://rbalsleymsft.github.io"
|
||||
|
||||
# Because you’ll publish as a project site at /FFU
|
||||
baseurl: "/FFU"
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
{% if page.url == '/' or page.url == '/index.html' %}
|
||||
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
|
||||
{% endif %}
|
||||
{% if page.url == '/' or page.url == '/index.html' %}
|
||||
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
|
||||
{% endif %}
|
||||
<!-- docs/_includes/head_custom.html -->
|
||||
<style>
|
||||
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
|
||||
@@ -57,6 +63,13 @@
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Lists: increase indentation (Learn-like) */
|
||||
/* Just-the-Docs draws bullets/numbers via ::before with negative offsets */
|
||||
.main-content ul,
|
||||
.main-content ol {
|
||||
padding-left: 2.25em;
|
||||
}
|
||||
|
||||
.main-content h1,
|
||||
.main-content h2,
|
||||
.main-content h3 {
|
||||
@@ -132,8 +145,16 @@
|
||||
margin-right: 0;
|
||||
justify-self: stretch;
|
||||
|
||||
/* Safety net: if anything still overflows, don't let it render under the TOC */
|
||||
overflow-x: hidden;
|
||||
/* Keep heading permalink icons visible */
|
||||
/* (Just-the-Docs positions .anchor-heading to the left at desktop widths) */
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
/* Safety net: prevent long code/tables from overlapping the TOC */
|
||||
.main-content-wrap.has-page-toc .main-content pre,
|
||||
.main-content-wrap.has-page-toc .main-content .highlighter-rouge,
|
||||
.main-content-wrap.has-page-toc .main-content .table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* TOC always stays in the right column */
|
||||
@@ -190,6 +211,77 @@
|
||||
border-left-color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline "In this article" TOC (narrow viewports; Learn-like) */
|
||||
.page-toc.page-toc--inline {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid #eeebee;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__title {
|
||||
font-weight: 600;
|
||||
color: #27262b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__list {
|
||||
list-style: none !important;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__item {
|
||||
list-style: none !important;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__item::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__item--h3 {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.125rem 0 0.125rem 0.75rem;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.page-toc--inline .page-toc__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-toc__item.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Just-the-Docs renders UL bullets via li::before; disable for inline TOC */
|
||||
.page-toc--inline ul > li::before,
|
||||
.page-toc--inline .page-toc__list > li::before,
|
||||
.page-toc--inline .page-toc__item::before {
|
||||
content: "" !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.page-toc__toggle {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0.25rem 0 0 0.75rem;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-toc__toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
|
||||
@@ -197,3 +289,4 @@
|
||||
<script src="{{ '/assets/js/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
|
||||
<script src="{{ '/assets/js/image-zoom.js' | relative_url }}" defer></script>
|
||||
<script src="{{ '/assets/js/page-toc.js' | relative_url }}" defer></script>
|
||||
<script src="{{ '/assets/js/external-links.js' | relative_url }}" defer></script>
|
||||
@@ -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,7 +57,6 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
|
||||
},
|
||||
"BuildUSBDrive": false,
|
||||
"CleanupAppsISO": true,
|
||||
"CleanupCaptureISO": true,
|
||||
"CleanupDeployISO": true,
|
||||
"CleanupDrivers": false,
|
||||
"CompactOS": true,
|
||||
@@ -69,7 +68,6 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
|
||||
"CopyPEDrivers": false,
|
||||
"CopyPPKG": false,
|
||||
"CopyUnattend": false,
|
||||
"CreateCaptureMedia": true,
|
||||
"CreateDeploymentMedia": true,
|
||||
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
|
||||
"Disksize": 53687091200,
|
||||
@@ -101,7 +99,6 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
|
||||
"RemoveApps": false,
|
||||
"RemoveFFU": false,
|
||||
"RemoveUpdates": false,
|
||||
"ShareName": "FFUCaptureShare",
|
||||
"Threads": 5,
|
||||
"UpdateADK": true,
|
||||
"UpdateEdge": true,
|
||||
@@ -115,9 +112,7 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
|
||||
"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",
|
||||
@@ -133,6 +128,4 @@ Example command line to run with vmwaretools set to false and foo set to foo. Th
|
||||
|
||||
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
|
||||
|
||||
|
||||
|
||||
{% include page_nav.html %}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function HasToken(tokens, token) {
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i] === token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function AddRelToken(anchor, token) {
|
||||
var rel = (anchor.getAttribute('rel') || '').trim();
|
||||
var tokens = rel ? rel.split(/\s+/) : [];
|
||||
|
||||
if (!HasToken(tokens, token)) {
|
||||
tokens.push(token);
|
||||
}
|
||||
|
||||
anchor.setAttribute('rel', tokens.join(' ').trim());
|
||||
}
|
||||
|
||||
function IsExternalHttpLink(url) {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.origin !== window.location.origin;
|
||||
}
|
||||
|
||||
function InitExternalLinksNewTab() {
|
||||
var mainContent = document.querySelector('.main-content');
|
||||
if (!mainContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
var anchors = mainContent.querySelectorAll('a[href]');
|
||||
for (var i = 0; i < anchors.length; i++) {
|
||||
var anchor = anchors[i];
|
||||
var href = (anchor.getAttribute('href') || '').trim();
|
||||
|
||||
if (!href) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.charAt(0) === '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0 || href.indexOf('javascript:') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var url = null;
|
||||
try {
|
||||
url = new URL(href, window.location.href);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsExternalHttpLink(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = (anchor.getAttribute('target') || '').trim();
|
||||
|
||||
if (!target) {
|
||||
anchor.setAttribute('target', '_blank');
|
||||
target = '_blank';
|
||||
}
|
||||
|
||||
if (target === '_blank') {
|
||||
AddRelToken(anchor, 'noopener');
|
||||
AddRelToken(anchor, 'noreferrer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', InitExternalLinksNewTab);
|
||||
return;
|
||||
}
|
||||
|
||||
InitExternalLinksNewTab();
|
||||
})();
|
||||
@@ -1,6 +1,10 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var scrollSpyDispose = null;
|
||||
var resizeReinitTimerId = null;
|
||||
var inlineMaxVisibleItems = 4;
|
||||
|
||||
function IsRightTocEnabled() {
|
||||
var meta = document.querySelector('meta[name="ffu-right-toc"]');
|
||||
if (meta && meta.content && meta.content.toLowerCase() === 'false') {
|
||||
@@ -18,6 +22,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
function RemoveExistingToc() {
|
||||
if (scrollSpyDispose) {
|
||||
scrollSpyDispose();
|
||||
scrollSpyDispose = null;
|
||||
}
|
||||
|
||||
var existingTocs = document.querySelectorAll('.page-toc');
|
||||
for (var i = 0; i < existingTocs.length; i++) {
|
||||
existingTocs[i].remove();
|
||||
}
|
||||
|
||||
var wrap = document.querySelector('.main-content-wrap');
|
||||
if (wrap) {
|
||||
wrap.classList.remove('has-page-toc');
|
||||
}
|
||||
}
|
||||
|
||||
function InsertInlineToc(main, toc) {
|
||||
if (!main || !toc) {
|
||||
return;
|
||||
}
|
||||
|
||||
var title = main.querySelector('h1');
|
||||
if (title && title.parentNode === main) {
|
||||
if (title.nextSibling) {
|
||||
main.insertBefore(toc, title.nextSibling);
|
||||
return;
|
||||
}
|
||||
|
||||
main.appendChild(toc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (main.firstChild) {
|
||||
main.insertBefore(toc, main.firstChild);
|
||||
return;
|
||||
}
|
||||
|
||||
main.appendChild(toc);
|
||||
}
|
||||
|
||||
function GetHeadings(container) {
|
||||
var headings = container.querySelectorAll('h2, h3');
|
||||
var results = [];
|
||||
@@ -49,9 +94,13 @@
|
||||
return results;
|
||||
}
|
||||
|
||||
function BuildToc(headings) {
|
||||
function BuildToc(headings, options) {
|
||||
var variant = (options && options.variant) ? options.variant : 'right';
|
||||
var maxVisible = (options && options.maxVisible) ? options.maxVisible : 0;
|
||||
var isInline = 'inline' === variant;
|
||||
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'page-toc';
|
||||
nav.className = 'page-toc' + (isInline ? ' page-toc--inline' : '');
|
||||
nav.setAttribute('aria-label', 'On this page');
|
||||
|
||||
var title = document.createElement('div');
|
||||
@@ -61,6 +110,7 @@
|
||||
|
||||
var list = document.createElement('ul');
|
||||
list.className = 'page-toc__list';
|
||||
list.id = 'page-toc-list';
|
||||
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var item = headings[i];
|
||||
@@ -75,13 +125,61 @@
|
||||
|
||||
li.appendChild(a);
|
||||
list.appendChild(li);
|
||||
|
||||
if (isInline && maxVisible > 0 && i >= maxVisible) {
|
||||
li.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
nav.appendChild(list);
|
||||
|
||||
if (isInline && maxVisible > 0 && headings.length > maxVisible) {
|
||||
var hiddenCount = headings.length - maxVisible;
|
||||
var isExpanded = false;
|
||||
|
||||
var toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'page-toc__toggle';
|
||||
toggle.setAttribute('aria-controls', list.id);
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
|
||||
function SetToggleText() {
|
||||
if (isExpanded) {
|
||||
toggle.textContent = 'Show less';
|
||||
} else {
|
||||
toggle.textContent = 'Show ' + hiddenCount + ' more';
|
||||
}
|
||||
}
|
||||
|
||||
function SetHiddenState() {
|
||||
var items = list.querySelectorAll('.page-toc__item');
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
if (j >= maxVisible) {
|
||||
if (isExpanded) {
|
||||
items[j].classList.remove('is-hidden');
|
||||
} else {
|
||||
items[j].classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
|
||||
SetToggleText();
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', function () {
|
||||
isExpanded = !isExpanded;
|
||||
SetHiddenState();
|
||||
});
|
||||
|
||||
SetHiddenState();
|
||||
nav.appendChild(toggle);
|
||||
}
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
function SetActiveTocLink(toc, activeId) {
|
||||
function SetActiveTocLink(toc, activeId, keepVisibleInPanel) {
|
||||
if (!toc) {
|
||||
return;
|
||||
}
|
||||
@@ -95,12 +193,14 @@
|
||||
if (isActive) {
|
||||
link.classList.add('is-active');
|
||||
|
||||
/* Keep the active item visible inside the TOC panel */
|
||||
if (keepVisibleInPanel) {
|
||||
/* Keep the active item visible inside the TOC panel (desktop/right TOC only) */
|
||||
try {
|
||||
link.scrollIntoView({ block: 'nearest' });
|
||||
} catch (e) {
|
||||
link.scrollIntoView();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
link.classList.remove('is-active');
|
||||
}
|
||||
@@ -109,12 +209,12 @@
|
||||
|
||||
function SetupScrollSpy(main, toc, headings) {
|
||||
if (!main || !toc || !headings || headings.length < 1) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Scrollspy is desktop-only; on mobile it can cause "fighting" scroll behavior */
|
||||
/* Scrollspy is desktop-only */
|
||||
if (!IsDesktopViewport()) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var headingElements = [];
|
||||
@@ -126,7 +226,7 @@
|
||||
}
|
||||
|
||||
if (headingElements.length < 1) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var activeId = null;
|
||||
@@ -143,7 +243,7 @@
|
||||
}
|
||||
|
||||
function GetCurrentHeadingId() {
|
||||
/* If we're at the bottom, force the last heading active (Learn-like behavior) */
|
||||
/* If we're at the bottom, force the last heading active */
|
||||
if (IsNearBottomOfPage()) {
|
||||
return headingElements[headingElements.length - 1].getAttribute('id');
|
||||
}
|
||||
@@ -177,6 +277,11 @@
|
||||
function Update() {
|
||||
ticking = false;
|
||||
|
||||
/* If the viewport becomes narrow after load, avoid scroll fighting */
|
||||
if (!IsDesktopViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() < lockActiveUntilMs) {
|
||||
return;
|
||||
}
|
||||
@@ -187,7 +292,7 @@
|
||||
}
|
||||
|
||||
activeId = currentId;
|
||||
SetActiveTocLink(toc, activeId);
|
||||
SetActiveTocLink(toc, activeId, true);
|
||||
}
|
||||
|
||||
function OnScrollOrResize() {
|
||||
@@ -199,11 +304,7 @@
|
||||
window.requestAnimationFrame(Update);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
|
||||
window.addEventListener('resize', OnScrollOrResize);
|
||||
|
||||
/* Update immediately and also when clicking TOC links */
|
||||
toc.addEventListener('click', function (evt) {
|
||||
function OnTocClick(evt) {
|
||||
var target = evt.target;
|
||||
if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
|
||||
return;
|
||||
@@ -223,29 +324,25 @@
|
||||
lockActiveUntilMs = Date.now() + 800;
|
||||
|
||||
activeId = id;
|
||||
SetActiveTocLink(toc, activeId);
|
||||
});
|
||||
SetActiveTocLink(toc, activeId, true);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
|
||||
window.addEventListener('resize', OnScrollOrResize);
|
||||
toc.addEventListener('click', OnTocClick);
|
||||
|
||||
Update();
|
||||
|
||||
return function DisposeScrollSpy() {
|
||||
window.removeEventListener('scroll', OnScrollOrResize);
|
||||
window.removeEventListener('resize', OnScrollOrResize);
|
||||
toc.removeEventListener('click', OnTocClick);
|
||||
};
|
||||
}
|
||||
|
||||
function InitRightToc() {
|
||||
if (!IsRightTocEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Desktop-only TOC: on mobile it interferes with scrolling */
|
||||
if (!IsDesktopViewport()) {
|
||||
var existingWrap = document.querySelector('.main-content-wrap');
|
||||
if (existingWrap) {
|
||||
var existingToc = existingWrap.querySelector('.page-toc');
|
||||
if (existingToc) {
|
||||
existingToc.remove();
|
||||
}
|
||||
|
||||
existingWrap.classList.remove('has-page-toc');
|
||||
}
|
||||
|
||||
RemoveExistingToc();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,31 +353,51 @@
|
||||
|
||||
var headings = GetHeadings(main);
|
||||
if (headings.length < 2) {
|
||||
RemoveExistingToc();
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsDesktopViewport()) {
|
||||
RemoveExistingToc();
|
||||
|
||||
var wrap = document.querySelector('.main-content-wrap');
|
||||
var content = document.querySelector('.main-content');
|
||||
if (!wrap || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (wrap.querySelector('.page-toc')) {
|
||||
if (!wrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.classList.add('has-page-toc');
|
||||
|
||||
var toc = BuildToc(headings);
|
||||
var toc = BuildToc(headings, { variant: 'right' });
|
||||
wrap.appendChild(toc);
|
||||
|
||||
SetupScrollSpy(main, toc, headings);
|
||||
scrollSpyDispose = SetupScrollSpy(main, toc, headings);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Narrow viewports: place TOC at the top of the article (Learn-like) */
|
||||
RemoveExistingToc();
|
||||
|
||||
var inlineToc = BuildToc(headings, { variant: 'inline', maxVisible: inlineMaxVisibleItems });
|
||||
InsertInlineToc(main, inlineToc);
|
||||
}
|
||||
|
||||
function OnViewportResize() {
|
||||
if (null !== resizeReinitTimerId) {
|
||||
window.clearTimeout(resizeReinitTimerId);
|
||||
}
|
||||
|
||||
resizeReinitTimerId = window.setTimeout(InitRightToc, 150);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', InitRightToc);
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
InitRightToc();
|
||||
window.addEventListener('resize', OnViewportResize);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
InitRightToc();
|
||||
window.addEventListener('resize', OnViewportResize);
|
||||
})();
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Create PE Media
|
||||
nav_order: 1
|
||||
prev_url: /helper_scripts.html
|
||||
prev_label: Helper Scripts
|
||||
next_url: /usb_imaging_tool_creator.html
|
||||
next_label: USB Imaging Tool Creator
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
## Common use case
|
||||
|
||||
If your staging location does not already have a deployment ISO, run `Create-PEMedia.ps1` to generate one, then copy that ISO to the staging folder used by your technicians.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Run from an elevated PowerShell session.
|
||||
- Windows ADK + WinPE add-on must be installed (default path: `C:\Program Files (x86)\Windows Kits\10\`).
|
||||
- Script should be run from the `FFUDevelopment` folder (or provide explicit paths via parameters).
|
||||
|
||||
## Quick start (deploy ISO)
|
||||
|
||||
From `FFUDevelopment`, this creates a deploy ISO by default:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1
|
||||
```
|
||||
|
||||
Default output file:
|
||||
|
||||
- `.\WinPE_FFU_Deploy_x64.iso`
|
||||
|
||||
## Useful commands
|
||||
|
||||
Create deploy ISO for x64:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1 -WindowsArch 'x64'
|
||||
```
|
||||
|
||||
Create deploy ISO for ARM64:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1 -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso"
|
||||
```
|
||||
|
||||
Create deploy ISO and include PE drivers from `.\PEDrivers`:
|
||||
|
||||
```powershell
|
||||
.\Create-PEMedia.ps1 -CopyPEDrivers $true
|
||||
```
|
||||
|
||||
## Stage output for USB imaging
|
||||
|
||||
After creating the deploy ISO, place it in the same staging root used for USB media creation.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
\\Server\FFUStaging\
|
||||
WinPE_FFU_Deploy_x64.iso
|
||||
FFU\
|
||||
<image files>.ffu
|
||||
Drivers\
|
||||
<optional driver content>
|
||||
```
|
||||
|
||||
Then technicians can run:
|
||||
|
||||
```powershell
|
||||
.\USBImagingToolCreator.ps1 -DeployISOPath "\\Server\FFUStaging\WinPE_FFU_Deploy_x64.iso" -DisableAutoPlay
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
`Create-PEMedia.ps1` writes log output to:
|
||||
|
||||
- `.\Create-PEMedia.log` (or custom path via `-LogFile`)
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Helper Scripts
|
||||
nav_order: 4
|
||||
prev_url: /ui_overview.html
|
||||
prev_label: UI Overview
|
||||
next_url: /create_pemedia.html
|
||||
next_label: Create PE Media
|
||||
has_children: true
|
||||
has_toc: false
|
||||
---
|
||||
# Helper Scripts
|
||||
|
||||
This section documents standalone helper scripts used outside the primary UI-driven build workflow.
|
||||
|
||||
## Available helper scripts
|
||||
|
||||
- [Create PE Media](/create_pemedia.html)
|
||||
- [USB Imaging Tool Creator](/usb_imaging_tool_creator.html)
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -9,17 +9,19 @@ 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.
|
||||
|
||||
## VM Host IP Address
|
||||
|
||||
IP address of the selected Hyper-V switch that will be used for FFU capture. The UI will auto-detected this based on the VM Switch that was selected.
|
||||
|
||||
If `$InstallApps` is set to `$true`, this parameter must be configured.
|
||||
This setting is 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.
|
||||
|
||||
## Disk Size (GB)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 608 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Monitor
|
||||
nav_order: 11
|
||||
prev_url: /build.html
|
||||
prev_label: Build
|
||||
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.
|
||||
|
||||
You can click into the monitor and select one or multiple lines (either single click, or shift+click). Doing so will prevent the log file from continuous scrolling, allowing you time to read what has transpired up to that point.
|
||||
|
||||
You can also copy your selection using ctrl+C. This makes it easy to copy and paste parts of the log file used for troubleshooting.
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Parameters Reference
|
||||
nav_order: 1
|
||||
parent: Reference
|
||||
prev_url: /reference.html
|
||||
prev_label: Reference
|
||||
---
|
||||
# BuildFFUVM.ps1 Parameter Reference
|
||||
|
||||
This table lists all top-level parameters in BuildFFUVM.ps1.
|
||||
|
||||
<style>
|
||||
.parameters-reference-table th:first-child,
|
||||
.parameters-reference-table td:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
| Parameter | Type | UI Control | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| -AdditionalFFUFiles | string[] | Copy Additional FFU Files + Additional FFU Files list | Array of full file paths to existing FFU files that should also be copied to the deployment USB when -CopyAdditionalFFUFiles is set to $true. |
|
||||
| -AllowExternalHardDiskMedia | bool | Allow External Hard Disk Media | When set to $true, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. |
|
||||
| -AllowVHDXCaching | bool | Allow VHDX Caching | When set to $true, will cache the VHDX file to the $FFUDevelopmentPath\VHDXCache folder and create a config json file that will keep track of the Windows build information, the updates installed, and the logical sector byte size information. Default is $false. |
|
||||
| -AppListPath | string | AppList.json Path | Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json. |
|
||||
| -AppsScriptVariables | hashtable | Define Apps Script Variables + Apps Script Variables list | When passed a hashtable, the script will create an AppsScriptVariables.json file in the OrchestrationPath. This file will be used to pass variables to the Apps script. The hashtable should contain key-value pairs where the key is the variable name and the value is the variable value. |
|
||||
| -BitsPriority | string | BITS Priority | BITS transfer priority used for downloads. Accepted values are 'Foreground', 'High', 'Normal', and 'Low'. Default is 'Normal'. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -CompactOS | bool | Compact OS | When set to $true, will compact the OS when building the FFU. Default is $true. |
|
||||
| -CompressDownloadedDriversToWim | bool | Compress Driver Model Folder to WIM | When set to $true, compresses downloaded drivers into a WIM file. Default is $false. |
|
||||
| -ConfigFile | string | Load Config File | Path to a JSON file containing parameters to use for the script. Default is $null. |
|
||||
| -CopyAdditionalFFUFiles | bool | Copy Additional FFU Files | When set to $true, enables copying additional FFU files from $FFUDevelopmentPath\FFU to the deployment USB alongside the current build output. |
|
||||
| -CopyAutopilot | bool | Copy Autopilot Profile | When set to $true, will copy the $FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is $false. |
|
||||
| -CopyDrivers | bool | Copy Drivers to USB drive | When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. |
|
||||
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -ISOPath | string | Windows ISO Path | Path to the Windows 10/11 ISO file. |
|
||||
| -LogicalSectorSizeBytes | uint32 | Logical Sector Size | UInt32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512. |
|
||||
| -Make | string | Make | Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo'. |
|
||||
| -MaxUSBDrives | int | Max USB Drives | Maximum number of USB drives to build in parallel. Default is 5. Set to 0 to process all discovered drives (or all selected drives when USBDriveList or selection is used). Actual throttle will never exceed the number of drives discovered. |
|
||||
| -MediaType | string | Media Type | String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'. |
|
||||
| -Memory | uint64 | Memory (GB) | Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB. |
|
||||
| -Model | string | Driver Models list | Model of the device to download drivers. This is required if Make is set. |
|
||||
| -OfficeConfigXMLFile | string | Office Configuration XML File | Path to a custom Office configuration XML file to use for installation. |
|
||||
| -Optimize | bool | Optimize | When set to $true, will optimize the FFU file. Default is $true. |
|
||||
| -OptionalFeatures | string | Optional Features | Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP). |
|
||||
| -OrchestrationPath | string | Application Path (derived Orchestration path) | Path to the orchestration folder containing scripts that run inside the VM. Default is $FFUDevelopmentPath\Apps\Orchestration. |
|
||||
| -PEDriversFolder | string | PE Drivers Folder | Path to the folder containing drivers to be injected into the WinPE deployment media. Default is $FFUDevelopmentPath\PEDrivers. |
|
||||
| -Processors | int | Processors | Number of virtual processors for the virtual machine. Recommended to use at least 4. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -UpdateLatestDefender | bool | Update Defender | When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false. |
|
||||
| -UpdateLatestMicrocode | bool | Update Latest Microcode (for LTSC/Server 2016/2019) | When set to $true, will download and install the latest microcode updates for applicable Windows releases (e.g., Windows Server 2016/2019, Windows 10 LTSC 2016/2019) into the FFU. Default is $false. |
|
||||
| -UpdateLatestMSRT | bool | Update Microsoft Software Removal Tool (MSRT) | When set to $true, will download and install the latest Windows Malicious Software Removal Tool. Default is $false. |
|
||||
| -UpdateLatestNet | bool | Update .NET | When set to $true, will download and install the latest .NET Framework. Default is $false. |
|
||||
| -UpdateOneDrive | bool | Update OneDrive (Per-Machine) | When set to $true, will download and install the latest OneDrive and install it as a per-machine installation instead of per-user. Default is $false. |
|
||||
| -UpdatePreviewCU | bool | Update Preview Cumulative Update | When set to $true, will download and install the latest Preview cumulative update. Default is $false. |
|
||||
| -USBDriveList | hashtable | USB Drives list | A hashtable containing USB drives from win32_diskdrive where:<br>- Key: USB drive model name (partial match supported)<br>- Value: USB drive UniqueId string, or an array of UniqueIds (to support selecting multiple drives with the same model)<br><br>Examples:<br>@{ "SanDisk Ultra" = "1234567890" }<br>@{ "SanDisk Ultra" = @("1234567890", "ABCDEFG"); "Kingston DataTraveler" = "0987654321" } |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -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. |
|
||||
| -WindowsSKU | string | Windows SKU | Edition/SKU to install. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N', 'Enterprise 2016 LTSB', 'Enterprise N 2016 LTSB', 'Enterprise LTSC', 'Enterprise N LTSC', 'IoT Enterprise LTSC', 'IoT Enterprise N LTSC', 'Standard', 'Standard (Desktop Experience)', 'Datacenter', 'Datacenter (Desktop Experience)'. |
|
||||
| -WindowsVersion | string | Windows Version | String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '25h2'. |
|
||||
{: .parameters-reference-table }
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -26,7 +26,7 @@ Follow the guide linked below to install Hyper-V on Windows client or Server
|
||||
|
||||
## Install PowerShell 7
|
||||
|
||||
PowerShell 7 is required as of releases 2507+ onward.
|
||||
PowerShell 7.6+ 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, 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`).
|
||||
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`).
|
||||
|
||||
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.5+ 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.6+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1`
|
||||
|
||||
If all went well, you should see the FFU Builder UI
|
||||
|
||||

|
||||

|
||||
|
||||
{% include page_nav.html %}
|
||||
|
||||
@@ -25,6 +25,24 @@ After following this guide, you will have a USB drive with an FFU that contains
|
||||
* Drivers (Optional)
|
||||
* In some cases drivers aren't necessary and you can get away with Windows Update providing drivers. We'll go over how to add drivers via the UI for Microsoft, HP, Lenovo, or Dell devices
|
||||
|
||||
## 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"
|
||||
title="YouTube video player"
|
||||
style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting started.
|
||||
@@ -33,7 +51,9 @@ Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting
|
||||
|
||||
Click the Hyper-V Settings tab
|
||||
|
||||
You should be able to keep these settings at the defaults. For VM Switch Name and VM Host IP Address, you'll want to make sure the switch you created in the prerequisites section is listed. FFU Builder should automatically figure out the IP address of the swtich for you.
|
||||
You should be able to keep these settings at the defaults. **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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -41,7 +61,7 @@ One setting you might need to set is the Logical Sector Size. 512 is the default
|
||||
|
||||
Click the Windows Settings tab
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Change the Windows language to the one of your choosing.
|
||||
|
||||
@@ -154,7 +174,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.
|
||||
|
||||
@@ -172,18 +192,46 @@ Another safety measure is **Select Specific USB Drives**. When you check **Selec
|
||||
|
||||
**Device Naming**
|
||||
|
||||
Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this:
|
||||
Use the **Device Naming** expander on the Build page to decide whether `ComputerName` should be set during deployment. There are some major benefits to doing this:
|
||||
|
||||
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
|
||||
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
|
||||
|
||||
**Prompt for Device Name**
|
||||
**No Device Name**
|
||||
|
||||
If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive.
|
||||
This is the default option. The unattend file is still applied, but Windows generates a random computer name.
|
||||
|
||||
**Specifying Multiple Name Prefixes**
|
||||
**Specify Device Name**
|
||||
|
||||
If you have multiple device name prefixes for different locations or device use cases, or even a single prefix, you can specify a prefixes.txt file in the `C:\FFUDevelopment\unattend` folder. If the prefixes.txt file is detected and a single prefix is listed, the device will just use that prefix and append the serial number of the device. If there are multiple prefixes listed in the prefixes.txt file, you will be prompted to select which prefix you want to name the device and the serial number will be appended to that prefix. If you want a dash in the name, include the dash in the prefix (e.g. if ABCD- is in the prefixes.txt file, the device name will be ABCD-SerialNumber).
|
||||
Use this option when you want a static device name or a template such as `Comp-%serial%`.
|
||||
|
||||
- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
|
||||
- With **Inject Unattend.xml**, only static names are supported.
|
||||
- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
|
||||
|
||||
**Specify a list of Prefixes**
|
||||
|
||||
This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line or import an existing prefixes file. The source file can use any name because the UI tracks the prefixes path separately. If there is one prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one and the serial number is appended to that prefix.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `<ComputerName>*</ComputerName>`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
|
||||
|
||||
**Specify Serial to Device Name Mapping**
|
||||
|
||||
This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the CSV and applies the matching computer name.
|
||||
|
||||
- This option requires **Copy Unattend.xml**.
|
||||
- **Inject Unattend.xml** is not supported with this option.
|
||||
- If no matching serial number is found during deployment, `ApplyFFU.ps1` falls back to a random `FFU-*` computer name.
|
||||
|
||||
{: .note-title}
|
||||
|
||||
> Note
|
||||
>
|
||||
> If `prefixes.txt` and `SerialComputerNames.csv` are both present on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder stages only the naming file for the selected device-naming mode.
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
@@ -191,13 +239,23 @@ If you have multiple device name prefixes for different locations or device 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**
|
||||
|
||||
@@ -205,8 +263,6 @@ 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-.**
|
||||
@@ -258,18 +314,22 @@ 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>MyComputer</ComputerName>
|
||||
<ComputerName>*</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, get prompted for a device name, and the deployment of the FFU and drivers should happen automatically.
|
||||
Deployment should be fairly straight forward: boot off the USB device and the deployment of the FFU and drivers should happen automatically. If you selected **Prompt for Device Name** or another supported device naming option, that naming step will happen during deployment.
|
||||
|
||||
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Reference
|
||||
nav_order: 5
|
||||
prev_url: /helper_scripts.html
|
||||
prev_label: Helper Scripts
|
||||
next_url: /parameters_reference.html
|
||||
next_label: Parameters Reference
|
||||
has_children: true
|
||||
has_toc: false
|
||||
---
|
||||
# Reference
|
||||
|
||||
This section contains deep-dive documentation that explains how FFU Builder works behind the UI.
|
||||
|
||||
## Available reference guides
|
||||
|
||||
- [BuildFFUVM Parameter Reference](/FFU/parameters_reference.html)
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://rbalsleymsft.github.io/FFU/sitemap.xml
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
layout: null
|
||||
---
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
{% assign sortedPages = site.pages | sort: "url" %}
|
||||
{% for page in sortedPages %}
|
||||
{% if page.sitemap != false and page.url != "/404.html" %}
|
||||
{% if page.url == "/" or page.url contains ".html" %}
|
||||
<url>
|
||||
<loc>{{ site.url }}{{ site.baseurl }}{{ page.url | replace: "index.html", "" }}</loc>
|
||||
<lastmod>{{ site.time | date_to_xmlschema }}</lastmod>
|
||||
</url>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</urlset>
|
||||
@@ -9,6 +9,6 @@ has_toc: false
|
||||
---
|
||||
# UI Overview
|
||||
|
||||

|
||||

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

|
||||

|
||||
|
||||
## Update Latest Cumulative Update
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: USB Imaging Tool Creator
|
||||
nav_order: 2
|
||||
prev_url: /create_pemedia.html
|
||||
prev_label: Create PE Media
|
||||
parent: Helper Scripts
|
||||
---
|
||||
# USB Imaging Tool Creator
|
||||
|
||||
`USBImagingToolCreator.ps1` is a standalone helper for creating one or more deployment USB drives from a deploy ISO, FFU files, and optional drivers. This is best used when you want to provide remote technicians the FFU file(s) you've built and optionally a drivers folder that contains the drivers for the models they will need to manage. They can also provide their own drivers (using Drivers\Make\Model format (e.g Drivers\Dell\Optiplex 7060 (085D))
|
||||
|
||||
## How the script works
|
||||
|
||||
- `-DeployISOPath` is required and should point to the deploy ISO file.
|
||||
- The script uses the parent folder of that ISO as its working root.
|
||||
- FFU files are copied from `<ISO parent>\FFU` (all `.ffu` files, recursive).
|
||||
- Drivers are copied from `<ISO parent>\Drivers` (recursive) when present.
|
||||
- If drivers are not found, the script creates an empty `Drivers` folder on each deploy partition.
|
||||
- `-DisableAutoPlay` is optional and temporarily disables AutoPlay for the current user during media creation. This is useful in situations where you see File Explorer pop ups as it's building the USB drive.
|
||||
|
||||
## Network share workflow (admin/technician)
|
||||
|
||||
For a shared workflow, stage one folder on a share with the deploy ISO and content that technicians should copy to USB drives.
|
||||
|
||||
If you do not already have a deployment ISO in the staging location, create one first using [Create PE Media](/FFU/create_pemedia.html). This lets admins quickly generate the deploy ISO and then stage it for technicians using `USBImagingToolCreator.ps1`.
|
||||
|
||||
Example layout:
|
||||
|
||||
```text
|
||||
\\Server\FFUStaging\
|
||||
WinPE_FFU_Deploy.iso
|
||||
FFU\
|
||||
<image files>.ffu
|
||||
Drivers\
|
||||
<optional driver content>
|
||||
```
|
||||
|
||||
Run from an elevated PowerShell session:
|
||||
|
||||
```powershell
|
||||
.\USBImagingToolCreator.ps1 -DeployISOPath "\\Server\FFUStaging\WinPE_FFU_Deploy.iso" -DisableAutoPlay
|
||||
```
|
||||
|
||||
The script passes `-DeployISOPath` directly to `Mount-DiskImage`, so use a path the local Windows host can mount.
|
||||
|
||||
## Example folder structure
|
||||
|
||||
In this example a folder named USBCreator was made and the Drivers and FFU folders as well as the WinPE_FFU_Deploy_x64.iso and USBImagingToolCreator.ps1 files were copied from the FFUDevelopment folder to this new folder.
|
||||
|
||||

|
||||
|
||||
## What happens when you run it
|
||||
|
||||
1. Detects disks with media type **Removable media** or **External hard disk media**.
|
||||
2. Prompts for a single drive selection or an all-drives selection.
|
||||
3. Stops `mmc` and `diskpart` processes to reduce drive lock issues.
|
||||
4. Erases each selected disk and rebuilds it as MBR with:
|
||||
- `Boot` partition (2 GB, FAT32, active)
|
||||
- `Deploy` partition (remaining space, NTFS)
|
||||
5. Mounts the deploy ISO and copies all ISO content to every `Boot` partition.
|
||||
6. Copies FFU content to every `Deploy` partition.
|
||||
7. Copies driver content into `Deploy\Drivers` (or creates an empty `Drivers` folder).
|
||||
8. Dismounts the ISO and reports completion.
|
||||
|
||||
{: .warning-title}
|
||||
|
||||
> Warning
|
||||
>
|
||||
> Selected disks are fully erased (`Clear-Disk -RemoveData -RemoveOEM`), so verify drive selection carefully, especially when using the all-drives option.
|
||||
|
||||
## Logging and progress
|
||||
|
||||
- Progress is shown in the PowerShell progress UI.
|
||||
- `Script.log` is written in the same folder as the deploy ISO (the working root folder).
|
||||
|
||||
{% include page_nav.html %}
|
||||
@@ -9,11 +9,21 @@ parent: UI Overview
|
||||
---
|
||||
# Windows Settings
|
||||
|
||||

|
||||

|
||||
|
||||
## Windows ISO Path
|
||||
## Windows Media Source
|
||||
|
||||
Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the latest version of Windows 10 or 11 from the Media Creation Tool.
|
||||
### Download Windows ESD
|
||||
|
||||
Download Windows ESD will download the ESD file provided from the Windows Media Creation Tool
|
||||
|
||||
### Provide Windows ISO
|
||||
|
||||
You can provide your own Windows ISO (Client or Server). Good for scenarios where you want to deploy Enterprise or Education SKUs and have a product key you want to use (MAK or KMS).
|
||||
|
||||
#### Windows ISO Path
|
||||
|
||||
Path to Windows ISO file.
|
||||
|
||||
{: .tip-title}
|
||||
|
||||
@@ -21,11 +31,11 @@ Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the lat
|
||||
>
|
||||
> Should I provide my own ISO, or let FFU Builder download the media
|
||||
>
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
## Windows Release
|
||||
|
||||
Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11.
|
||||
Can be 10, 11, different Server values, or LTSB/LTSC.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||