Add optional data partitions and Apps ISO discovery

Support configurable data partitions after Recovery, including UI/config persistence, VHDX cache layout signatures, fixed-size-before-fill-remaining creation, and safer Windows/Recovery partition detection during capture and apply.

Replace hard-coded D:\ Apps ISO assumptions with FFUAppsRoot discovery, token expansion, legacy D:\ BYO app path remapping, and a logged audit-mode orchestration bootstrap.

Update BYO apps, Hyper-V settings, and parameter documentation.
This commit is contained in:
rbalsleyMSFT
2026-06-22 18:44:20 -07:00
parent 56a2597818
commit a025812404
19 changed files with 983 additions and 61 deletions
@@ -1,8 +1,13 @@
#Requires -RunAsAdministrator
param(
[Parameter()]
[string]$BasePath = $(if ([string]::IsNullOrWhiteSpace([string]$env:FFUAppsRoot)) { "D:\MSStore" } else { Join-Path -Path ([string]$env:FFUAppsRoot).Trim().TrimEnd('\') -ChildPath "MSStore" })
)
# --- CONFIGURATION ---
# Base path where application folders are located. Each subfolder represents one application.
$basePath = "D:\MSStore"
$basePath = $BasePath
# Path for temporary files (e.g., for extracting archives). This will be created and cleaned up automatically.
$tempBasePath = Join-Path -Path $env:TEMP -ChildPath "StoreAppInstall"
@@ -6,6 +6,102 @@ param(
[string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json")
)
function Get-AppsMediaRoot {
$appsRoot = [string]$env:FFUAppsRoot
if ([string]::IsNullOrWhiteSpace($appsRoot)) {
return $null
}
return $appsRoot.Trim().TrimEnd('\')
}
function Expand-AppsMediaRootTokens {
param(
[AllowNull()]
[string]$Value
)
if ($null -eq $Value) {
return $null
}
$appsRoot = Get-AppsMediaRoot
if ([string]::IsNullOrWhiteSpace($appsRoot)) {
return $Value
}
$tokenPattern = '%FFUAppsRoot%|\$\{?env:FFUAppsRoot\}?|\{FFUAppsRoot\}'
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
return [regex]::Replace($Value, $tokenPattern, [System.Text.RegularExpressions.MatchEvaluator] { param($match) $appsRoot }, $regexOptions)
}
function Resolve-LegacyAppsMediaPath {
param(
[Parameter(Mandatory)]
[string]$Path
)
if ($Path -notmatch '^(?i)d:\\') {
return $Path
}
$appsRoot = Get-AppsMediaRoot
if ([string]::IsNullOrWhiteSpace($appsRoot)) {
return $Path
}
$relativePath = $Path.Substring(3)
$candidatePath = Join-Path -Path $appsRoot -ChildPath $relativePath
$literalExists = Test-Path -Path $Path
$candidateExists = Test-Path -Path $candidatePath
if ($literalExists) {
if ($candidateExists) {
Write-Warning "Both legacy Apps path '$Path' and Apps media path '$candidatePath' exist. Keeping literal path."
}
return $Path
}
if ($candidateExists) {
Write-Host "Remapped legacy Apps path '$Path' to '$candidatePath'."
return $candidatePath
}
return $Path
}
function Resolve-AppsMediaPathValue {
param(
[AllowNull()]
[string]$Value
)
if ($null -eq $Value) {
return $null
}
$resolvedValue = Expand-AppsMediaRootTokens -Value $Value
if ($resolvedValue -match '^(?i)d:\\') {
return Resolve-LegacyAppsMediaPath -Path $resolvedValue
}
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
$resolvedValue = [regex]::Replace($resolvedValue, '"(?<path>d:\\[^"\r\n]+)"', [System.Text.RegularExpressions.MatchEvaluator] {
param($match)
'"' + (Resolve-LegacyAppsMediaPath -Path $match.Groups['path'].Value) + '"'
}, $regexOptions)
$resolvedValue = [regex]::Replace($resolvedValue, '''(?<path>d:\\[^''\r\n]+)''', [System.Text.RegularExpressions.MatchEvaluator] {
param($match)
"'" + (Resolve-LegacyAppsMediaPath -Path $match.Groups['path'].Value) + "'"
}, $regexOptions)
$resolvedValue = [regex]::Replace($resolvedValue, '(?<![\w:"''])(?<path>d:\\[^\s"'']+)', [System.Text.RegularExpressions.MatchEvaluator] {
param($match)
Resolve-LegacyAppsMediaPath -Path $match.Groups['path'].Value
}, $regexOptions)
return $resolvedValue
}
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
@@ -198,17 +294,19 @@ function Install-Applications {
}
try {
$commandLineToRun = Resolve-AppsMediaPathValue -Value $app.CommandLine
# Normalize arguments: treat null/empty/whitespace as no arguments
$argumentsToPass = $null
if ($null -ne $app.Arguments) {
if ($app.Arguments -is [array]) {
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { Resolve-AppsMediaPathValue -Value $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
if ($trimmed.Count -gt 0) {
$argumentsToPass = $trimmed
}
}
else {
$single = $app.Arguments.ToString().Trim()
$single = Resolve-AppsMediaPathValue -Value $app.Arguments.ToString().Trim()
if (-not [string]::IsNullOrWhiteSpace($single)) {
$argumentsToPass = @($single)
}
@@ -231,19 +329,19 @@ function Install-Applications {
# Auto-quote MSI paths if using msiexec and path contains spaces but no quotes
if ($null -ne $argumentsToPass -and $argumentsToPass.Count -gt 0) {
$joinedArgs = $argumentsToPass -join ' '
$formattedArgs = Format-MsiArguments -CommandLine $app.CommandLine -Arguments $joinedArgs
$formattedArgs = Format-MsiArguments -CommandLine $commandLineToRun -Arguments $joinedArgs
if ($formattedArgs -ne $joinedArgs) {
$argumentsToPass = @($formattedArgs)
}
}
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
Write-Host "Running command: $($app.CommandLine) (no arguments)"
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
Write-Host "Running command: $commandLineToRun (no arguments)"
$result = Invoke-Process -FilePath $commandLineToRun -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
}
else {
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
$result = Invoke-Process -FilePath $app.CommandLine -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
Write-Host "Running command: $commandLineToRun $($argumentsToPass -join ' ')"
$result = Invoke-Process -FilePath $commandLineToRun -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
}
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
}
@@ -27,6 +27,51 @@ Write-Host "---------------------------------------------------" -ForegroundColo
# Define the path to the scripts
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$appsMediaRoot = Split-Path -Parent $scriptPath
if ([string]::IsNullOrWhiteSpace([string]$env:FFUAppsRoot)) {
$env:FFUAppsRoot = $appsMediaRoot
}
else {
$env:FFUAppsRoot = ([string]$env:FFUAppsRoot).Trim().TrimEnd('\')
}
Write-Host "Using Apps media root: $env:FFUAppsRoot"
function Resolve-AppsMediaPath {
param(
[Parameter(Mandatory)]
[string]$Path
)
$appsRoot = ([string]$env:FFUAppsRoot).Trim().TrimEnd('\')
if (-not [string]::IsNullOrWhiteSpace($appsRoot)) {
$tokenPattern = '%FFUAppsRoot%|\$\{?env:FFUAppsRoot\}?|\{FFUAppsRoot\}'
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
$Path = [regex]::Replace($Path, $tokenPattern, [System.Text.RegularExpressions.MatchEvaluator] { param($match) $appsRoot }, $regexOptions)
}
if ($Path -notmatch '^(?i)d:\\') {
return $Path
}
if ([string]::IsNullOrWhiteSpace($appsRoot)) {
return $Path
}
$candidatePath = Join-Path -Path $appsRoot -ChildPath $Path.Substring(3)
if (Test-Path -Path $Path) {
if (Test-Path -Path $candidatePath) {
Write-Warning "Both legacy Apps path '$Path' and Apps media path '$candidatePath' exist. Keeping literal path."
}
return $Path
}
if (Test-Path -Path $candidatePath) {
Write-Host "Remapped legacy Apps path '$Path' to '$candidatePath'."
return $candidatePath
}
return $Path
}
# Resolve the configured BYO app list path for runtime orchestration.
$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json"
@@ -36,7 +81,7 @@ 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
$userAppsJsonFile = Resolve-AppsMediaPath -Path $appInstallConfig.UserAppListPath
Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile"
}
}
@@ -73,7 +118,7 @@ foreach ($script in $scriptList) {
}
}
"Install-StoreApps.ps1" {
$msStorePath = "D:\MSStore"
$msStorePath = Join-Path -Path $env:FFUAppsRoot -ChildPath "MSStore"
if (-not (Test-Path -Path $msStorePath) -or -not (Get-ChildItem -Path $msStorePath)) {
$shouldRun = $false
}
@@ -89,6 +134,9 @@ foreach ($script in $scriptList) {
if ($script -eq "Install-Win32Apps.ps1") {
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
}
elseif ($script -eq "Install-StoreApps.ps1") {
& $scriptFile -BasePath (Join-Path -Path $env:FFUAppsRoot -ChildPath "MSStore")
}
else {
& $scriptFile
}
@@ -78,9 +78,10 @@ else {
Write-Host "No per-user, non-provisioned Appx packages detected."
}
# If an Unattend.xml has been provided on the mounted Apps ISO (D:\Unattend\Unattend.xml),
# If an Unattend.xml has been provided on the mounted Apps ISO,
# pass it to sysprep; otherwise, run without /unattend.
$unattendOnAppsIso = "D:\Unattend\Unattend.xml"
$appsMediaRoot = if ([string]::IsNullOrWhiteSpace([string]$env:FFUAppsRoot)) { "D:" } else { ([string]$env:FFUAppsRoot).Trim().TrimEnd('\') }
$unattendOnAppsIso = Join-Path -Path $appsMediaRoot -ChildPath "Unattend\Unattend.xml"
if (Test-Path -Path $unattendOnAppsIso) {
Write-Host "Using $unattendOnAppsIso from Apps ISO..."
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe /unattend:$unattendOnAppsIso
@@ -0,0 +1,46 @@
$ErrorActionPreference = 'Stop'
$logPath = 'C:\Windows\Temp\FFUOrchestrationBootstrap.log'
Start-Transcript -Path $logPath -Append -Force | Out-Null
try {
Write-Host 'Starting FFU orchestration bootstrap.'
$deadline = (Get-Date).AddMinutes(10)
$orchestratorPath = $null
do {
$fileSystemDrives = @(Get-PSDrive -PSProvider FileSystem | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.Root) })
foreach ($driveInfo in $fileSystemDrives) {
$candidatePath = Join-Path -Path $driveInfo.Root -ChildPath 'Orchestration\Orchestrator.ps1'
Write-Host "Checking for orchestrator at $candidatePath"
if (Test-Path -Path $candidatePath -PathType Leaf) {
$orchestratorPath = $candidatePath
break
}
}
if (-not [string]::IsNullOrWhiteSpace($orchestratorPath)) {
break
}
Write-Host 'Apps ISO orchestrator was not found yet. Waiting before retry.'
Start-Sleep -Seconds 5
} while ((Get-Date) -lt $deadline)
if ([string]::IsNullOrWhiteSpace($orchestratorPath)) {
throw 'Unable to locate Apps ISO orchestrator after waiting for Apps media.'
}
$env:FFUAppsRoot = Split-Path -Parent (Split-Path -Parent $orchestratorPath)
Write-Host "Using Apps media root: $env:FFUAppsRoot"
Write-Host "Launching orchestrator: $orchestratorPath"
& $orchestratorPath
Write-Host 'FFU orchestrator completed.'
}
catch {
Write-Error "FFU orchestration bootstrap failed: $($_.Exception.Message)"
throw
}
finally {
Stop-Transcript | Out-Null
}
@@ -5,7 +5,7 @@
<RunAsynchronous>
<RunAsynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Setup\Scripts\Start-FFUOrchestration.ps1</Path>
</RunAsynchronousCommand>
</RunAsynchronous>
</component>
@@ -5,7 +5,7 @@
<RunAsynchronous>
<RunAsynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
<Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\Windows\Setup\Scripts\Start-FFUOrchestration.ps1</Path>
</RunAsynchronousCommand>
</RunAsynchronous>
</component>
+252 -30
View File
@@ -105,6 +105,15 @@ Sets a custom FFU output name with placeholders. Allowed placeholders are: {Wind
.PARAMETER Disksize
Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk.
.PARAMETER OSPartitionSize
Fixed size of the Windows partition in bytes. Required when AdditionalDataPartitions are configured so space remains for Recovery and data partitions.
.PARAMETER RecoveryPartitionSize
Optional fixed size of the Recovery partition in bytes. Leave as 0 to calculate the Recovery partition size from winre.wim plus buffer space.
.PARAMETER AdditionalDataPartitions
Optional data partitions to create after the Recovery partition. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, and FileSystem.
.PARAMETER DriversFolder
Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers.
@@ -177,6 +186,9 @@ Path to a custom Office configuration XML file to use for installation.
.PARAMETER Optimize
When set to $true, will optimize the FFU file. Default is $true.
.PARAMETER OptimizeFFUPartitionNumber
Optional partition number to pass to DISM /Optimize-FFU /PartitionNumber. Leave as 0 to optimize with DISM defaults.
.PARAMETER OptionalFeatures
Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP).
@@ -352,6 +364,9 @@ param(
[bool]$InstallDrivers,
[uint64]$Memory = 4GB,
[uint64]$Disksize = 50GB,
[uint64]$OSPartitionSize = 0,
[uint64]$RecoveryPartitionSize = 0,
[object[]]$AdditionalDataPartitions = @(),
[int]$Processors = 4,
[bool]$EnableVMNetworking,
[string]$VMSwitchName,
@@ -424,6 +439,7 @@ param(
[string]$WindowsPartitionDriveLetter = 'W',
[string]$RecoveryPartitionDriveLetter = 'R',
[bool]$Optimize = $true,
[int]$OptimizeFFUPartitionNumber = 0,
[string]$DriversJsonPath,
[bool]$CompressDownloadedDriversToWim = $false,
[bool]$CopyDrivers,
@@ -521,14 +537,18 @@ if ($ConfigFile -and (Test-Path -Path $ConfigFile)) {
# Iterate through the keys in the config data
foreach ($key in $keys) {
$value = $configdata.$key
$valueIsEmptyString = ($value -is [string]) -and [string]::IsNullOrEmpty($value)
$valueIsEmptyArray = ($value -is [System.Array]) -and ($value.Count -eq 0)
$valueIsEmptyHashtable = ($value -is [System.Collections.Hashtable]) -and ($value.Count -eq 0)
$valueIsZero = (($value -is [System.UInt32]) -or ($value -is [System.UInt64]) -or ($value -is [System.Int32])) -and ($value -eq 0)
# If $value is empty, skip
if ($null -eq $value -or
([string]::IsNullOrEmpty([string]$value)) -or
($value -is [System.Collections.Hashtable] -and $value.Count -eq 0) -or
($value -is [System.UInt32] -and $value -eq 0) -or
($value -is [System.UInt64] -and $value -eq 0) -or
($value -is [System.Int32] -and $value -eq 0)) {
$valueIsEmptyString -or
$valueIsEmptyArray -or
$valueIsEmptyHashtable -or
$valueIsZero) {
continue
}
@@ -881,6 +901,7 @@ class VhdxCacheItem {
[string]$SystemPartitionDriveLetter = ""
[string]$WindowsPartitionDriveLetter = ""
[string]$RecoveryPartitionDriveLetter = ""
[string]$PartitionLayoutSignature = ""
[string]$WindowsSKU = ""
[string]$WindowsRelease = ""
[string]$WindowsVersion = ""
@@ -2506,7 +2527,7 @@ function Get-Office {
WriteLog "Creating $orchestrationpath\Install-Office.ps1"
$installOfficePath = Join-Path -Path $orchestrationpath -ChildPath "Install-Office.ps1"
# Create the Install-Office.ps1 file
$installOfficeCommand = "& d:\Office\setup.exe /configure d:\office\$OfficeInstallXML"
$installOfficeCommand = "& `"`$env:FFUAppsRoot\Office\setup.exe`" /configure `"`$env:FFUAppsRoot\Office\$OfficeInstallXML`""
# Back up any pre-existing script with the same name before overwrite.
Backup-RunFile -FFUDevelopmentPath $FFUDevelopmentPath -Path $installOfficePath
Set-Content -Path $installOfficePath -Value $installOfficeCommand -Force
@@ -2738,10 +2759,10 @@ function Sync-UserAppListForOrchestration {
WriteLog "Using BYO app list already staged at $stagedUserAppListPath"
}
$appInstallConfig.UserAppListPath = "D:\$stagedUserAppListName"
$appInstallConfig.UserAppListPath = "%FFUAppsRoot%\$stagedUserAppListName"
}
elseif (Test-Path -Path (Join-Path -Path $AppsPath -ChildPath 'UserAppList.json') -PathType Leaf) {
$appInstallConfig.UserAppListPath = "D:\UserAppList.json"
$appInstallConfig.UserAppListPath = "%FFUAppsRoot%\UserAppList.json"
WriteLog "Using default BYO app list path for orchestration."
}
else {
@@ -3068,6 +3089,7 @@ function Get-NormalizedPartitionDriveLetters {
[string]$SystemPartitionDriveLetter,
[string]$WindowsPartitionDriveLetter,
[string]$RecoveryPartitionDriveLetter,
[object[]]$AdditionalDataPartitions = @(),
[switch]$ValidateAvailable
)
@@ -3076,6 +3098,17 @@ function Get-NormalizedPartitionDriveLetters {
WindowsPartitionDriveLetter = $WindowsPartitionDriveLetter
RecoveryPartitionDriveLetter = $RecoveryPartitionDriveLetter
}
foreach ($dataPartition in @($AdditionalDataPartitions)) {
if ($null -eq $dataPartition) { continue }
$partitionName = [string]$dataPartition.Name
if ([string]::IsNullOrWhiteSpace($partitionName)) {
$partitionName = [string]$dataPartition.Label
}
if ([string]::IsNullOrWhiteSpace($partitionName)) {
$partitionName = "DataPartition$($requestedLetters.Count - 2)"
}
$requestedLetters["AdditionalDataPartition:$partitionName"] = $dataPartition.DriveLetter
}
$normalizedLetters = [ordered]@{}
foreach ($entry in $requestedLetters.GetEnumerator()) {
@@ -3104,6 +3137,107 @@ function Get-NormalizedPartitionDriveLetters {
return [pscustomobject]$normalizedLetters
}
function ConvertTo-UInt64SizeBytes {
param(
[object]$Value,
[string]$PropertyName
)
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
return 0
}
[uint64]$sizeBytes = 0
if (-not [uint64]::TryParse([string]$Value, [ref]$sizeBytes)) {
throw "$PropertyName must be a whole number."
}
return $sizeBytes
}
function ConvertTo-NormalizedDataPartitions {
param(
[object[]]$DataPartitions = @()
)
$normalizedPartitions = [System.Collections.Generic.List[pscustomobject]]::new()
$partitionIndex = 0
foreach ($dataPartition in @($DataPartitions)) {
if ($null -eq $dataPartition) { continue }
$partitionIndex++
$name = [string]$dataPartition.Name
$label = [string]$dataPartition.Label
if ([string]::IsNullOrWhiteSpace($name)) { $name = "Data$partitionIndex" }
if ([string]::IsNullOrWhiteSpace($label)) { $label = $name }
$driveLetter = ([string]$dataPartition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
if ([string]::IsNullOrWhiteSpace($driveLetter) -or $driveLetter -notmatch '^[A-Z]$') {
throw "Additional data partition '$name' must specify a single drive letter from A to Z without a colon."
}
$fileSystem = [string]$dataPartition.FileSystem
if ([string]::IsNullOrWhiteSpace($fileSystem)) { $fileSystem = 'NTFS' }
if ($fileSystem -notin @('NTFS', 'ReFS', 'exFAT', 'FAT32')) {
throw "Additional data partition '$name' uses unsupported file system '$fileSystem'."
}
$sizeBytes = ConvertTo-UInt64SizeBytes -Value $dataPartition.SizeBytes -PropertyName "Additional data partition '$name' SizeBytes"
if ($sizeBytes -eq 0 -and $null -ne $dataPartition.SizeGB -and -not [string]::IsNullOrWhiteSpace([string]$dataPartition.SizeGB)) {
[decimal]$sizeGb = 0
if (-not [decimal]::TryParse([string]$dataPartition.SizeGB, [ref]$sizeGb)) {
throw "Additional data partition '$name' SizeGB must be a number."
}
$sizeBytes = [uint64]($sizeGb * 1GB)
}
$fillRemaining = $false
if ($dataPartition.PSObject.Properties.Name -contains 'FillRemaining') {
$fillRemaining = [System.Convert]::ToBoolean($dataPartition.FillRemaining)
}
if (($sizeBytes -eq 0) -and (-not $fillRemaining)) {
throw "Additional data partition '$name' must specify SizeBytes, SizeGB, or FillRemaining."
}
$normalizedPartitions.Add([pscustomobject]@{
Name = $name
Label = $label
DriveLetter = $driveLetter
FileSystem = $fileSystem
SizeBytes = $sizeBytes
FillRemaining = $fillRemaining
})
}
$fillRemainingPartitions = @($normalizedPartitions | Where-Object { $_.FillRemaining })
if ($fillRemainingPartitions.Count -gt 1) {
throw 'Only one additional data partition can use FillRemaining.'
}
if ($fillRemainingPartitions.Count -eq 1) {
$orderedPartitions = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($fixedSizePartition in @($normalizedPartitions | Where-Object { -not $_.FillRemaining })) {
$orderedPartitions.Add($fixedSizePartition)
}
$orderedPartitions.Add($fillRemainingPartitions[0])
return @($orderedPartitions)
}
return @($normalizedPartitions)
}
function Get-PartitionLayoutSignature {
param(
[uint64]$OSPartitionSize,
[uint64]$RecoveryPartitionSize,
[object[]]$DataPartitions = @()
)
$dataPartitionSignatures = @($DataPartitions | ForEach-Object {
"$($_.Name)|$($_.Label)|$($_.DriveLetter)|$($_.FileSystem)|$($_.SizeBytes)|$($_.FillRemaining)"
})
return "OS=$OSPartitionSize;Recovery=$RecoveryPartitionSize;Data=$($dataPartitionSignatures -join ';')"
}
function Get-PartitionDriveLetterCacheValue {
param(
[object]$DriveLetterValue
@@ -3205,7 +3339,8 @@ function New-RecoveryPartition {
$OsPartition,
[uint64]$RecoveryPartitionSize = 0,
[string]$DriveLetter = 'R',
[ciminstance]$DataPartition
[ciminstance]$DataPartition,
[bool]$OsPartitionUsesMaximumSize = $true
)
WriteLog "Creating empty Recovery partition (to be filled on first boot automatically)..."
@@ -3233,16 +3368,14 @@ function New-RecoveryPartition {
$DataPartition | Resize-Partition -Size ($DataPartition.Size - $calculatedRecoverySize)
WriteLog "Data partition shrunk by $calculatedRecoverySize bytes for Recovery partition."
}
else {
elseif ($OsPartitionUsesMaximumSize) {
$newOsPartitionSize = [math]::Floor(($OsPartition.Size - $calculatedRecoverySize) / 4096) * 4096
$OsPartition | Resize-Partition -Size $newOsPartitionSize
WriteLog "OS partition shrunk by $calculatedRecoverySize bytes for Recovery partition."
}
$recoveryPartition = $VhdxDisk | New-Partition -DriveLetter $DriveLetter -UseMaximumSize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" `
| Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel 'Recovery'
WriteLog "Done. Recovery partition at drive $($recoveryPartition.DriveLetter):"
else {
WriteLog 'Using free space reserved after the Windows partition for Recovery partition.'
}
}
else {
WriteLog "No WinRE.WIM found in the OS partition under \Windows\System32\Recovery."
@@ -3251,8 +3384,73 @@ function New-RecoveryPartition {
}
}
if ($calculatedRecoverySize -gt 0) {
$recoveryPartition = $VhdxDisk | New-Partition -DriveLetter $DriveLetter -Size $calculatedRecoverySize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" `
| Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel 'Recovery'
WriteLog "Done. Recovery partition at drive $($recoveryPartition.DriveLetter):"
}
return $recoveryPartition
}
#Add Data partition
function New-DataPartition {
param(
[Parameter(Mandatory = $true)]
[ciminstance]$VhdxDisk,
[Parameter(Mandatory = $true)]
[pscustomobject]$DataPartition
)
WriteLog "Creating data partition '$($DataPartition.Name)'..."
if ($DataPartition.FillRemaining) {
$partition = $VhdxDisk | New-Partition -DriveLetter $DataPartition.DriveLetter -UseMaximumSize -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" -ErrorAction Stop
}
else {
$partition = $VhdxDisk | New-Partition -DriveLetter $DataPartition.DriveLetter -Size $DataPartition.SizeBytes -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" -ErrorAction Stop
}
if ([string]::IsNullOrWhiteSpace([string]$partition.DriveLetter)) {
Set-Partition -DiskNumber $partition.DiskNumber -PartitionNumber $partition.PartitionNumber -NewDriveLetter $DataPartition.DriveLetter -ErrorAction Stop
$partition = Get-Partition -DiskNumber $partition.DiskNumber -PartitionNumber $partition.PartitionNumber -ErrorAction Stop
}
if (([string]$partition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant() -ne $DataPartition.DriveLetter) {
throw "Data partition '$($DataPartition.Name)' was created, but drive letter $($DataPartition.DriveLetter): was not assigned."
}
$partition | Format-Volume -FileSystem $DataPartition.FileSystem -Confirm:$false -Force -NewFileSystemLabel $DataPartition.Label -ErrorAction Stop | Out-Null
WriteLog "Done. Data partition '$($DataPartition.Name)' at drive $($partition.DriveLetter):"
return $partition
}
function Get-WindowsPartitionFromDisk {
param(
[Parameter(Mandatory = $true)]
[ciminstance]$Disk,
[string]$DriveLetter
)
$basicDataPartitions = @($Disk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' })
$normalizedDriveLetter = ([string]$DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
if (-not [string]::IsNullOrWhiteSpace($normalizedDriveLetter)) {
$driveLetterPartition = $basicDataPartitions | Where-Object { ([string]$_.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant() -eq $normalizedDriveLetter } | Select-Object -First 1
if ($null -ne $driveLetterPartition) {
return $driveLetterPartition
}
}
foreach ($partition in $basicDataPartitions) {
if ([string]::IsNullOrWhiteSpace($partition.DriveLetter)) { continue }
$volume = Get-Volume -DriveLetter $partition.DriveLetter -ErrorAction SilentlyContinue
if ($null -ne $volume -and $volume.FileSystemLabel -eq 'Windows') {
return $partition
}
}
return $basicDataPartitions | Select-Object -First 1
}
#Add boot files
function Add-BootFiles {
param(
@@ -4008,7 +4206,7 @@ function Optimize-FFUCaptureDrive {
}
# Resolve the OS partition drive letter used for volume-level optimization
$osPartition = $mountedDisk | Get-Partition | Where-Object { $_.GptType -eq "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" }
$osPartition = Get-WindowsPartitionFromDisk -Disk $mountedDisk -DriveLetter $WindowsPartitionDriveLetter
if ($null -eq $osPartition -or [string]::IsNullOrWhiteSpace($osPartition.DriveLetter)) {
throw 'Unable to resolve Windows partition drive letter for VHDX optimization.'
}
@@ -4063,7 +4261,7 @@ function Get-CaptureVhdContext {
$captureDisk = Mount-VHD -Path $VhdxPath -Passthru | Get-Disk
}
$captureOsPartition = $captureDisk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' } | Select-Object -First 1
$captureOsPartition = Get-WindowsPartitionFromDisk -Disk $captureDisk -DriveLetter $WindowsPartitionDriveLetter
if ($null -eq $captureOsPartition) {
throw 'Unable to resolve Windows partition for FFU capture.'
}
@@ -4263,7 +4461,11 @@ function New-FFU {
Set-Progress -Percentage 85 -Message "Optimizing FFU..."
WriteLog 'Optimizing FFU - This will take a few minutes, please be patient'
#Need to use ADK version of DISM to address bug in DISM - perhaps Windows 11 24H2 will fix this
Invoke-Process cmd "/c ""$DandIEnv"" && dism /optimize-ffu /imagefile:$FFUFile" | Out-Null
$optimizePartitionArgument = if ($OptimizeFFUPartitionNumber -gt 0) { " /PartitionNumber:$OptimizeFFUPartitionNumber" } else { '' }
if (-not [string]::IsNullOrWhiteSpace($optimizePartitionArgument)) {
WriteLog "Optimizing FFU with DISM partition number $OptimizeFFUPartitionNumber."
}
Invoke-Process cmd "/c ""$DandIEnv"" && dism /optimize-ffu /imagefile:$FFUFile$optimizePartitionArgument" | Out-Null
#Invoke-Process cmd "/c dism /optimize-ffu /imagefile:$FFUFile" | Out-Null
WriteLog 'Optimizing FFU complete'
Set-Progress -Percentage 90 -Message "FFU post-processing complete."
@@ -5973,11 +6175,18 @@ Set-Progress -Percentage 2 -Message "Validating parameters..."
#Set build partition drive letters and validate they are available for use; this is required before any build steps that require drive access to ensure the expected drive letters are reserved and to fail fast if there are conflicts.
try {
$partitionDriveLetters = Get-NormalizedPartitionDriveLetters -SystemPartitionDriveLetter $SystemPartitionDriveLetter -WindowsPartitionDriveLetter $WindowsPartitionDriveLetter -RecoveryPartitionDriveLetter $RecoveryPartitionDriveLetter -ValidateAvailable
$normalizedAdditionalDataPartitions = ConvertTo-NormalizedDataPartitions -DataPartitions $AdditionalDataPartitions
if ($normalizedAdditionalDataPartitions.Count -gt 0 -and $OSPartitionSize -le 0) {
throw 'OSPartitionSize must be set when AdditionalDataPartitions are configured so space remains for Recovery and data partitions.'
}
$partitionLayoutSignature = Get-PartitionLayoutSignature -OSPartitionSize $OSPartitionSize -RecoveryPartitionSize $RecoveryPartitionSize -DataPartitions $normalizedAdditionalDataPartitions
$partitionDriveLetters = Get-NormalizedPartitionDriveLetters -SystemPartitionDriveLetter $SystemPartitionDriveLetter -WindowsPartitionDriveLetter $WindowsPartitionDriveLetter -RecoveryPartitionDriveLetter $RecoveryPartitionDriveLetter -AdditionalDataPartitions $normalizedAdditionalDataPartitions -ValidateAvailable
$SystemPartitionDriveLetter = $partitionDriveLetters.SystemPartitionDriveLetter
$WindowsPartitionDriveLetter = $partitionDriveLetters.WindowsPartitionDriveLetter
$RecoveryPartitionDriveLetter = $partitionDriveLetters.RecoveryPartitionDriveLetter
WriteLog "Using build partition drive letters: System=$SystemPartitionDriveLetter, Windows=$WindowsPartitionDriveLetter, Recovery=$RecoveryPartitionDriveLetter"
$dataPartitionDriveLetterLog = if ($normalizedAdditionalDataPartitions.Count -gt 0) { ', Data=' + (($normalizedAdditionalDataPartitions | ForEach-Object { "$($_.Name):$($_.DriveLetter)" }) -join ', ') } else { '' }
WriteLog "Using build partition drive letters: System=$SystemPartitionDriveLetter, Windows=$WindowsPartitionDriveLetter, Recovery=$RecoveryPartitionDriveLetter$dataPartitionDriveLetterLog"
}
catch {
$partitionDriveLetterValidationError = "Build validation failed: $($_.Exception.Message)"
@@ -6819,7 +7028,7 @@ if ($InstallApps) {
$KBFilePath = Save-KB -Name $update.Name -Path $DefenderPath
WriteLog "Latest $($update.Description) saved to $DefenderPath\$KBFilePath"
# Add the KB file path to the installDefenderCommand
$installDefenderCommand += "& d:\Defender\$KBFilePath`r`n"
$installDefenderCommand += "& `"`$env:FFUAppsRoot\Defender\$KBFilePath`"`r`n"
}
# Download latest Defender Definitions
@@ -6835,7 +7044,7 @@ if ($InstallApps) {
WriteLog "Defender definitions URL is $DefenderDefURL"
Start-BitsTransferWithRetry -Source $DefenderDefURL -Destination "$DefenderPath\mpam-fe.exe"
WriteLog "Defender Definitions downloaded to $DefenderPath\mpam-fe.exe"
$installDefenderCommand += "& d:\Defender\mpam-fe.exe"
$installDefenderCommand += "& `"`$env:FFUAppsRoot\Defender\mpam-fe.exe`""
}
catch {
Write-Host "Downloading Defender Definitions Failed"
@@ -6903,7 +7112,7 @@ if ($InstallApps) {
# Create Update-MSRT.ps1
$installMSRTPath = Join-Path -Path $orchestrationPath -ChildPath "Update-MSRT.ps1"
WriteLog "Creating $installMSRTPath"
$installMSRTCommand = "& d:\MSRT\$MSRTFileName /quiet"
$installMSRTCommand = "& `"`$env:FFUAppsRoot\MSRT\$MSRTFileName`" /quiet"
# Back up any pre-existing script with the same name before overwrite.
Backup-RunFile -FFUDevelopmentPath $FFUDevelopmentPath -Path $installMSRTPath
Set-Content -Path $installMSRTPath -Value $installMSRTCommand -Force
@@ -6956,7 +7165,7 @@ if ($InstallApps) {
# Create Update-OneDrive.ps1
$installODPath = Join-Path -Path $orchestrationPath -ChildPath "Update-OneDrive.ps1"
WriteLog "Creating $installODPath"
$installODCommand = "& d:\OneDrive\OneDriveSetup.exe /allusers /silent"
$installODCommand = "& `"`$env:FFUAppsRoot\OneDrive\OneDriveSetup.exe`" /allusers /silent"
# Back up any pre-existing script with the same name before overwrite.
Backup-RunFile -FFUDevelopmentPath $FFUDevelopmentPath -Path $installODPath
Set-Content -Path $installODPath -Value $installODCommand -Force
@@ -7012,7 +7221,7 @@ if ($InstallApps) {
# Create Update-Edge.ps1
$installEdgePath = Join-Path -Path $orchestrationPath -ChildPath "Update-Edge.ps1"
WriteLog "Creating $installEdgePath"
$installEdgeCommand = "& d:\Edge\$EdgeMSIFileName /quiet /norestart"
$installEdgeCommand = "& `"`$env:FFUAppsRoot\Edge\$EdgeMSIFileName`" /quiet /norestart"
# Back up any pre-existing script with the same name before overwrite.
Backup-RunFile -FFUDevelopmentPath $FFUDevelopmentPath -Path $installEdgePath
Set-Content -Path $installEdgePath -Value $installEdgeCommand -Force
@@ -7286,6 +7495,8 @@ try {
if ($cachedSystemPartitionDriveLetter -ne $SystemPartitionDriveLetter) { WriteLog "SystemPartitionDriveLetter mismatch (cached: $($vhdxCacheItem.SystemPartitionDriveLetter), current: $SystemPartitionDriveLetter), continuing"; continue }
if ($cachedWindowsPartitionDriveLetter -ne $WindowsPartitionDriveLetter) { WriteLog "WindowsPartitionDriveLetter mismatch (cached: $($vhdxCacheItem.WindowsPartitionDriveLetter), current: $WindowsPartitionDriveLetter), continuing"; continue }
if ($cachedRecoveryPartitionDriveLetter -ne $RecoveryPartitionDriveLetter) { WriteLog "RecoveryPartitionDriveLetter mismatch (cached: $($vhdxCacheItem.RecoveryPartitionDriveLetter), current: $RecoveryPartitionDriveLetter), continuing"; continue }
if ($vhdxCacheItem.PSObject.Properties.Name -notcontains 'PartitionLayoutSignature') { WriteLog 'PartitionLayoutSignature missing in cached config, continuing'; continue }
if ($vhdxCacheItem.PartitionLayoutSignature -ne $partitionLayoutSignature) { WriteLog 'PartitionLayoutSignature mismatch, continuing'; continue }
$cachedUpdateNames = @()
if ($vhdxCacheItem.IncludedUpdates -and $vhdxCacheItem.IncludedUpdates.Count -gt 0) {
@@ -7612,8 +7823,11 @@ try {
$osPartitionDriveLetter = $osPartition[1].DriveLetter
$WindowsPartition = $osPartitionDriveLetter + ':\'
#$recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition
$recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DriveLetter $RecoveryPartitionDriveLetter -DataPartition $dataPartition
$recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DriveLetter $RecoveryPartitionDriveLetter -OsPartitionUsesMaximumSize ($OSPartitionSize -le 0)
foreach ($additionalDataPartition in $normalizedAdditionalDataPartitions) {
New-DataPartition -VhdxDisk $vhdxDisk -DataPartition $additionalDataPartition | Out-Null
}
WriteLog 'All necessary partitions created.'
@@ -7749,7 +7963,7 @@ try {
$VHDXPath = Join-Path $($VMPath) $($cachedVHDXInfo.VhdxFileName)
$vhdxDisk = Get-VHD -Path $VHDXPath | Mount-VHD -Passthru | Get-Disk
$osPartition = $vhdxDisk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' }
$osPartition = Get-WindowsPartitionFromDisk -Disk $vhdxDisk -DriveLetter $WindowsPartitionDriveLetter
$osPartitionDriveLetter = $osPartition.DriveLetter
$WindowsPartition = $osPartitionDriveLetter + ':\'
@@ -7791,6 +8005,7 @@ try {
$cachedVHDXInfo.SystemPartitionDriveLetter = [string]$SystemPartitionDriveLetter
$cachedVHDXInfo.WindowsPartitionDriveLetter = [string]$WindowsPartitionDriveLetter
$cachedVHDXInfo.RecoveryPartitionDriveLetter = [string]$RecoveryPartitionDriveLetter
$cachedVHDXInfo.PartitionLayoutSignature = $partitionLayoutSignature
$cachedVHDXInfo.WindowsSKU = $WindowsSKU
$cachedVHDXInfo.WindowsRelease = $WindowsRelease
$cachedVHDXInfo.WindowsVersion = $WindowsVersion
@@ -7893,7 +8108,7 @@ if ($InstallApps -and $installLatestCuInVm) {
# Create Install-LTSCUpdate.ps1 for in-VM execution via orchestrator
$installLtscUpdateCommand = @"
# Validate LTSC CU package exists on Apps ISO mount
`$kbPath = "D:\LTSCUpdate\$ltscCuFileName"
`$kbPath = Join-Path -Path `$env:FFUAppsRoot -ChildPath "LTSCUpdate\$ltscCuFileName"
# Extract KB ID from filename for idempotent checks
`$kbFileName = Split-Path -Path `$kbPath -Leaf
@@ -7990,10 +8205,17 @@ if ($InstallApps) {
WriteLog 'Mounting VHDX to inject unattend for audit-mode boot'
$disk = Mount-VHD -Path $VHDXPath -Passthru | Get-Disk
}
$osPartition = $disk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' }
$osPartition = Get-WindowsPartitionFromDisk -Disk $disk -DriveLetter $WindowsPartitionDriveLetter
$osPartitionDriveLetter = $osPartition.DriveLetter
WriteLog 'Copying unattend file to boot to audit mode'
New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\Unattend" -ItemType Directory -Force | Out-Null
$orchestrationBootstrapSourcePath = Join-Path -Path $FFUDevelopmentPath -ChildPath 'BuildFFUUnattend\Start-FFUOrchestration.ps1'
if (-not (Test-Path -Path $orchestrationBootstrapSourcePath -PathType Leaf)) {
throw "Orchestration bootstrap script not found at $orchestrationBootstrapSourcePath"
}
$orchestrationBootstrapTargetFolder = "$($osPartitionDriveLetter):\Windows\Setup\Scripts"
New-Item -Path $orchestrationBootstrapTargetFolder -ItemType Directory -Force | Out-Null
Copy-Item -Path $orchestrationBootstrapSourcePath -Destination (Join-Path -Path $orchestrationBootstrapTargetFolder -ChildPath 'Start-FFUOrchestration.ps1') -Force | Out-Null
if ($WindowsArch -eq 'x64') {
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
}
+14
View File
@@ -40,6 +40,7 @@ $script:uiState = [PSCustomObject]@{
Data = @{
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
additionalDataPartitionsDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
versionData = $null;
vmSwitchMap = @{};
logData = $null;
@@ -415,6 +416,12 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus = $script:uiState.Controls.txtStatus
$progressBar.Visibility = 'Visible'
$txtStatus.Text = "Starting FFU build..."
if (-not (Add-PendingAdditionalDataPartition -State $script:uiState)) {
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: data partition configuration incomplete."
return
}
# Gather config on the UI thread before starting the job
$config = Get-UIConfig -State $script:uiState
@@ -427,6 +434,13 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
if (($null -ne $config.AdditionalDataPartitions) -and ($config.AdditionalDataPartitions.Count -gt 0) -and ($config.OSPartitionSize -le 0)) {
[System.Windows.MessageBox]::Show("Set Windows Partition Size (GB) before adding data partitions so the VHDX has reserved space for Recovery and data partitions.", "Windows Partition Size Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: Windows partition size required for data partitions."
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
+71 -2
View File
@@ -429,6 +429,75 @@
<ComboBoxItem Content="Y"/>
<ComboBoxItem Content="Z"/>
</ComboBox>
<!-- Additional Data Partitions -->
<Expander Header="Additional Data Partitions" Margin="0,0,0,20" IsExpanded="False" ToolTip="Create optional data partitions after the Windows Recovery partition. Set Windows Partition Size when adding data partitions so free space remains on the VHDX.">
<StackPanel Margin="0,10,0,0">
<TextBlock Text="Windows Partition Size (GB)" Margin="0,0,0,8" ToolTip="Required when additional data partitions are configured. Leave blank to use the default Windows partition sizing behavior."/>
<TextBox x:Name="txtOSPartitionSizeGB" Width="120" HorizontalAlignment="Left" Margin="0,0,0,12" ToolTip="Size of the Windows partition in GB when additional data partitions are configured."/>
<TextBlock Text="Recovery Partition Size (GB)" Margin="0,0,0,8" ToolTip="Optional fixed Recovery partition size in GB. Leave blank to use the default calculated Recovery partition size."/>
<TextBox x:Name="txtRecoveryPartitionSizeGB" Width="120" HorizontalAlignment="Left" Margin="0,0,0,16" ToolTip="Optional size of the Recovery partition in GB."/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180"/>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Name" Margin="0,0,8,8"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="Drive Letter" Margin="0,0,8,8"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="Size (GB)" Margin="0,0,8,8"/>
<TextBlock Grid.Row="0" Grid.Column="3" Text="Fill Remaining" Margin="0,0,0,8"/>
<TextBox x:Name="txtDataPartitionName" Grid.Row="1" Grid.Column="0" Margin="0,0,8,0" ToolTip="Name and volume label for the data partition."/>
<ComboBox x:Name="cmbDataPartitionDriveLetter" Grid.Row="1" Grid.Column="1" Margin="0,0,8,0" ToolTip="Build-time drive letter for the data partition.">
<ComboBoxItem Content="D" IsSelected="True"/>
<ComboBoxItem Content="E"/>
<ComboBoxItem Content="F"/>
<ComboBoxItem Content="G"/>
<ComboBoxItem Content="H"/>
<ComboBoxItem Content="I"/>
<ComboBoxItem Content="J"/>
<ComboBoxItem Content="K"/>
<ComboBoxItem Content="L"/>
<ComboBoxItem Content="M"/>
<ComboBoxItem Content="N"/>
<ComboBoxItem Content="O"/>
<ComboBoxItem Content="P"/>
<ComboBoxItem Content="Q"/>
<ComboBoxItem Content="T"/>
<ComboBoxItem Content="U"/>
<ComboBoxItem Content="V"/>
<ComboBoxItem Content="X"/>
<ComboBoxItem Content="Y"/>
<ComboBoxItem Content="Z"/>
</ComboBox>
<TextBox x:Name="txtDataPartitionSizeGB" Grid.Row="1" Grid.Column="2" Margin="0,0,8,0" ToolTip="Data partition size in GB. Leave blank only when Fill Remaining is selected."/>
<CheckBox x:Name="chkDataPartitionFillRemaining" Grid.Row="1" Grid.Column="3" VerticalAlignment="Center" ToolTip="Use the remaining VHDX space for this data partition. Only one data partition can fill remaining space."/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Button x:Name="btnAddDataPartition" Content="Add Data Partition" Width="160" Margin="0,0,8,0" ToolTip="Add the configured data partition to the build configuration."/>
<Button x:Name="btnRemoveSelectedDataPartitions" Content="Remove Selected" Width="140" Margin="0,0,8,0" ToolTip="Remove selected data partitions from the list."/>
<Button x:Name="btnClearDataPartitions" Content="Clear" Width="80" ToolTip="Clear all additional data partitions."/>
</StackPanel>
<ListView x:Name="lstDataPartitions" Height="150" Margin="0" SelectionMode="Extended" BorderThickness="1" BorderBrush="{DynamicResource {x:Static SystemColors.ActiveBorderBrushKey}}" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="180"/>
<GridViewColumn Header="Drive Letter" DisplayMemberBinding="{Binding DriveLetter}" Width="100"/>
<GridViewColumn Header="Size (GB)" DisplayMemberBinding="{Binding SizeGB}" Width="100"/>
<GridViewColumn Header="Fill Remaining" DisplayMemberBinding="{Binding FillRemaining}" Width="120"/>
</GridView>
</ListView.View>
</ListView>
</StackPanel>
</Expander>
<!-- Logical Sector Size -->
<TextBlock Text="Logical Sector Size" Margin="0,0,0,8" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512."/>
<ComboBox x:Name="cmbLogicalSectorSize" HorizontalAlignment="Left" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.">
@@ -632,11 +701,11 @@
<!-- Command Line -->
<TextBlock Text="Command Line:" Margin="0,0,0,8"/>
<TextBox x:Name="txtAppCommandLine" Margin="0,0,0,20" ToolTip="Enter the full path to the command line to install the application. This should start with D:\Win32 for exe, cmd, etc types of deployments (e.g. D:\Win32\Mozilla FireFox\setup.exe). For MSI installs, use msiexec and then fill in the rest of the arguments in the arguments field."/>
<TextBox x:Name="txtAppCommandLine" Margin="0,0,0,20" ToolTip="Enter the full path to the command line to install the application. Use %FFUAppsRoot%\Win32 for exe, cmd, etc types of deployments (e.g. %FFUAppsRoot%\Win32\Mozilla FireFox\setup.exe). Legacy D:\Win32 paths are still supported. For MSI installs, use msiexec and then fill in the rest of the arguments in the arguments field."/>
<!-- Arguments -->
<TextBlock Text="Arguments:" Margin="0,0,0,8"/>
<TextBox x:Name="txtAppArguments" Margin="0,0,0,20" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i &quot;D:\Win32\Mozilla firefox\setup.msi&quot; /qn /norestart)."/>
<TextBox x:Name="txtAppArguments" Margin="0,0,0,20" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i &quot;%FFUAppsRoot%\Win32\Mozilla firefox\setup.msi&quot; /qn /norestart). Legacy D:\Win32 paths are still supported."/>
<!-- Source -->
<TextBlock Text="Source:" Margin="0,0,0,8"/>
@@ -1317,8 +1317,8 @@ function Add-Win32DependencySilentInstallCommands {
return 5
}
# Build the VM install base path for dependency payloads (matches D:\win32 layout)
$vmBasePath = "D:\win32\$ParentAppName"
# Build the VM install base path for dependency payloads on the Apps media.
$vmBasePath = "%FFUAppsRoot%\win32\$ParentAppName"
if (-not [string]::IsNullOrEmpty($SubFolder)) {
$vmBasePath = "$vmBasePath\$SubFolder"
}
@@ -1532,13 +1532,13 @@ function Add-Win32SilentInstallCommand {
}
}
# Build the VM install base path (matches D:\win32 layout)
# Build the VM install base path on the Apps media.
$basePath = $null
if (-not [string]::IsNullOrWhiteSpace($BasePathOverride)) {
$basePath = $BasePathOverride
}
else {
$basePath = "D:\win32\$AppFolder"
$basePath = "%FFUAppsRoot%\win32\$AppFolder"
if (-not [string]::IsNullOrEmpty($SubFolder)) {
$basePath = "$basePath\$SubFolder"
}
@@ -49,6 +49,9 @@ function Get-UIConfig {
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
OSPartitionSize = ConvertTo-PartitionSizeBytesFromGBText -Text $State.Controls.txtOSPartitionSizeGB.Text -FieldName 'Windows Partition Size'
RecoveryPartitionSize = ConvertTo-PartitionSizeBytesFromGBText -Text $State.Controls.txtRecoveryPartitionSizeGB.Text -FieldName 'Recovery Partition Size'
AdditionalDataPartitions = @(Get-AdditionalDataPartitionConfigRows -State $State)
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
@@ -84,6 +87,7 @@ function Get-UIConfig {
OfficeConfigXMLFile = $State.Controls.txtOfficeConfigXMLFilePath.Text
OfficePath = $State.Controls.txtOfficePath.Text
Optimize = $State.Controls.chkOptimize.IsChecked
OptimizeFFUPartitionNumber = 0
OptionalFeatures = (($State.Controls.featureCheckBoxes.GetEnumerator() | Where-Object { $_.Value.IsChecked } | ForEach-Object { $_.Key } | Sort-Object) -join ';')
OrchestrationPath = "$($State.Controls.txtApplicationPath.Text)\Orchestration"
PEDriversFolder = $State.Controls.txtPEDriversFolder.Text
@@ -157,6 +161,7 @@ function Get-UIConfig {
ForEach-Object { $_.FullName }
)
}
WriteLog "Get-UIConfig: Saving $($config.AdditionalDataPartitions.Count) additional data partition(s)."
return $config
}
@@ -274,6 +279,309 @@ function Set-UIValue {
}
}
function Get-ComboBoxSelectedContent {
param(
[System.Windows.Controls.ComboBox]$ComboBox
)
if ($null -eq $ComboBox -or $null -eq $ComboBox.SelectedItem) {
return $null
}
if ($ComboBox.SelectedItem -is [System.Windows.Controls.ComboBoxItem]) {
return [string]$ComboBox.SelectedItem.Content
}
return [string]$ComboBox.SelectedItem
}
function ConvertTo-PartitionSizeBytesFromGBText {
param(
[AllowNull()]
[object]$Text,
[string]$FieldName
)
if ($null -eq $Text -or [string]::IsNullOrWhiteSpace([string]$Text)) {
return [int64]0
}
$trimmedText = ([string]$Text).Trim()
[decimal]$sizeGb = 0
if (-not [decimal]::TryParse($trimmedText, [System.Globalization.NumberStyles]::Number, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$sizeGb)) {
throw "$FieldName must be a number in GB."
}
if ($sizeGb -lt 0) {
throw "$FieldName cannot be negative."
}
return [int64]($sizeGb * 1GB)
}
function Get-PartitionSizeGBDisplay {
param(
[object]$SizeBytes
)
[decimal]$parsedSizeBytes = 0
if ($null -eq $SizeBytes -or -not [decimal]::TryParse([string]$SizeBytes, [ref]$parsedSizeBytes) -or $parsedSizeBytes -le 0) {
return ''
}
$sizeGb = $parsedSizeBytes / 1GB
if (($parsedSizeBytes % 1GB) -eq 0) {
return ([int64]$sizeGb).ToString([System.Globalization.CultureInfo]::InvariantCulture)
}
return $sizeGb.ToString('0.##', [System.Globalization.CultureInfo]::InvariantCulture)
}
function Update-AdditionalDataPartitionsListView {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
if ($null -eq $State.Data.additionalDataPartitionsDataList) {
$State.Data.additionalDataPartitionsDataList = [System.Collections.Generic.List[PSCustomObject]]::new()
}
if ($null -ne $State.Controls.lstDataPartitions) {
$State.Controls.lstDataPartitions.ItemsSource = $State.Data.additionalDataPartitionsDataList.ToArray()
$State.Controls.lstDataPartitions.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDataPartitions
}
}
function Clear-AdditionalDataPartitionForm {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$State.Controls.txtDataPartitionName.Clear()
$State.Controls.txtDataPartitionSizeGB.Clear()
$State.Controls.chkDataPartitionFillRemaining.IsChecked = $false
$State.Controls.txtDataPartitionSizeGB.IsEnabled = $true
}
function Add-AdditionalDataPartition {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
if ($null -eq $State.Data.additionalDataPartitionsDataList) {
$State.Data.additionalDataPartitionsDataList = [System.Collections.Generic.List[PSCustomObject]]::new()
}
$partitionName = ([string]$State.Controls.txtDataPartitionName.Text).Trim()
if ([string]::IsNullOrWhiteSpace($partitionName)) {
$partitionName = "Data$($State.Data.additionalDataPartitionsDataList.Count + 1)"
}
if ($State.Data.additionalDataPartitionsDataList | Where-Object { $_.Name -ieq $partitionName } | Select-Object -First 1) {
[System.Windows.MessageBox]::Show("A data partition named '$partitionName' already exists.", "Duplicate Data Partition", "OK", "Warning") | Out-Null
return $false
}
$driveLetter = ([string](Get-ComboBoxSelectedContent -ComboBox $State.Controls.cmbDataPartitionDriveLetter)).Trim().TrimEnd(':').ToUpperInvariant()
if ([string]::IsNullOrWhiteSpace($driveLetter) -or $driveLetter -notmatch '^[A-Z]$') {
[System.Windows.MessageBox]::Show("Select a drive letter for the data partition.", "Data Partition Drive Letter", "OK", "Warning") | Out-Null
return $false
}
$reservedDriveLetters = @(
Get-ComboBoxSelectedContent -ComboBox $State.Controls.cmbSystemPartitionDriveLetter
Get-ComboBoxSelectedContent -ComboBox $State.Controls.cmbWindowsPartitionDriveLetter
Get-ComboBoxSelectedContent -ComboBox $State.Controls.cmbRecoveryPartitionDriveLetter
) | ForEach-Object { ([string]$_).Trim().TrimEnd(':').ToUpperInvariant() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($reservedDriveLetters -contains $driveLetter) {
[System.Windows.MessageBox]::Show("Drive letter $driveLetter is already used by a required build partition.", "Duplicate Drive Letter", "OK", "Warning") | Out-Null
return $false
}
if ($State.Data.additionalDataPartitionsDataList | Where-Object { $_.DriveLetter -eq $driveLetter } | Select-Object -First 1) {
[System.Windows.MessageBox]::Show("Drive letter $driveLetter is already used by another data partition.", "Duplicate Drive Letter", "OK", "Warning") | Out-Null
return $false
}
$fillRemaining = $true -eq $State.Controls.chkDataPartitionFillRemaining.IsChecked
if ($fillRemaining -and ($State.Data.additionalDataPartitionsDataList | Where-Object { $_.FillRemaining } | Select-Object -First 1)) {
[System.Windows.MessageBox]::Show("Only one data partition can fill the remaining VHDX space.", "Fill Remaining Already Used", "OK", "Warning") | Out-Null
return $false
}
$sizeBytes = [int64]0
$sizeGbDisplay = ''
if (-not $fillRemaining) {
try {
$sizeBytes = ConvertTo-PartitionSizeBytesFromGBText -Text $State.Controls.txtDataPartitionSizeGB.Text -FieldName 'Data Partition Size'
}
catch {
[System.Windows.MessageBox]::Show($_.Exception.Message, "Data Partition Size", "OK", "Warning") | Out-Null
return $false
}
if ($sizeBytes -le 0) {
[System.Windows.MessageBox]::Show("Enter a data partition size or select Fill Remaining.", "Data Partition Size", "OK", "Warning") | Out-Null
return $false
}
$sizeGbDisplay = Get-PartitionSizeGBDisplay -SizeBytes $sizeBytes
}
$newItem = [PSCustomObject]@{
Name = $partitionName
Label = $partitionName
DriveLetter = $driveLetter
SizeGB = $sizeGbDisplay
SizeBytes = $sizeBytes
FillRemaining = $fillRemaining
FileSystem = 'NTFS'
}
$State.Data.additionalDataPartitionsDataList.Add($newItem)
WriteLog "Added additional data partition '$partitionName' (DriveLetter=$driveLetter, SizeBytes=$sizeBytes, FillRemaining=$fillRemaining)."
Update-AdditionalDataPartitionsListView -State $State
Clear-AdditionalDataPartitionForm -State $State
return $true
}
function Add-PendingAdditionalDataPartition {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$hasPendingDataPartitionInput = -not [string]::IsNullOrWhiteSpace([string]$State.Controls.txtDataPartitionName.Text) -or
-not [string]::IsNullOrWhiteSpace([string]$State.Controls.txtDataPartitionSizeGB.Text) -or
($true -eq $State.Controls.chkDataPartitionFillRemaining.IsChecked)
if (-not $hasPendingDataPartitionInput) {
return $true
}
WriteLog 'Detected pending data partition form input. Adding it before saving build configuration.'
return ($true -eq (Add-AdditionalDataPartition -State $State))
}
function Remove-SelectedDataPartition {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$itemsToRemove = @($State.Controls.lstDataPartitions.SelectedItems)
if ($itemsToRemove.Count -eq 0) {
[System.Windows.MessageBox]::Show("Please select one or more data partitions to remove.", "Selection Required", "OK", "Warning") | Out-Null
return
}
foreach ($itemToRemove in $itemsToRemove) {
$State.Data.additionalDataPartitionsDataList.Remove($itemToRemove) | Out-Null
}
Update-AdditionalDataPartitionsListView -State $State
}
function Get-AdditionalDataPartitionConfigRows {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
if ($null -eq $State.Data.additionalDataPartitionsDataList) {
return @()
}
return @($State.Data.additionalDataPartitionsDataList | ForEach-Object {
[PSCustomObject]@{
Name = $_.Name
Label = $_.Label
DriveLetter = $_.DriveLetter
SizeBytes = [int64]$_.SizeBytes
FillRemaining = [bool]$_.FillRemaining
FileSystem = $_.FileSystem
}
})
}
function Import-AdditionalDataPartitionsFromConfig {
param(
[Parameter(Mandatory = $true)]
[psobject]$State,
[Parameter(Mandatory = $true)]
[object]$ConfigContent
)
if ($null -eq $State.Data.additionalDataPartitionsDataList) {
$State.Data.additionalDataPartitionsDataList = [System.Collections.Generic.List[PSCustomObject]]::new()
}
$State.Data.additionalDataPartitionsDataList.Clear()
$keyExists = $false
if ($ConfigContent -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigContent.PSObject.Properties) {
try {
if (($ConfigContent.PSObject.Properties.Match('AdditionalDataPartitions')).Count -gt 0) {
$keyExists = $true
}
}
catch {
WriteLog "ERROR: Exception while trying to Match key 'AdditionalDataPartitions'. Error: $($_.Exception.Message)"
}
}
if (-not $keyExists -or $null -eq $ConfigContent.AdditionalDataPartitions) {
Update-AdditionalDataPartitionsListView -State $State
return
}
$partitionIndex = 0
foreach ($partition in @($ConfigContent.AdditionalDataPartitions)) {
if ($null -eq $partition) { continue }
$partitionIndex++
$partitionName = [string]$partition.Name
$partitionLabel = [string]$partition.Label
if ([string]::IsNullOrWhiteSpace($partitionName)) { $partitionName = $partitionLabel }
if ([string]::IsNullOrWhiteSpace($partitionName)) { $partitionName = "Data$partitionIndex" }
if ([string]::IsNullOrWhiteSpace($partitionLabel)) { $partitionLabel = $partitionName }
$driveLetter = ([string]$partition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
$fileSystem = [string]$partition.FileSystem
if ([string]::IsNullOrWhiteSpace($fileSystem)) { $fileSystem = 'NTFS' }
$fillRemaining = $false
if ($partition.PSObject.Properties.Name -contains 'FillRemaining') {
try { $fillRemaining = [System.Convert]::ToBoolean($partition.FillRemaining) } catch { $fillRemaining = $false }
}
[int64]$sizeBytes = 0
if ($partition.PSObject.Properties.Name -contains 'SizeBytes') {
[int64]::TryParse([string]$partition.SizeBytes, [ref]$sizeBytes) | Out-Null
}
elseif ($partition.PSObject.Properties.Name -contains 'SizeGB') {
$sizeBytes = ConvertTo-PartitionSizeBytesFromGBText -Text $partition.SizeGB -FieldName "Data Partition '$partitionName' Size"
}
$State.Data.additionalDataPartitionsDataList.Add([PSCustomObject]@{
Name = $partitionName
Label = $partitionLabel
DriveLetter = $driveLetter
SizeGB = Get-PartitionSizeGBDisplay -SizeBytes $sizeBytes
SizeBytes = $sizeBytes
FillRemaining = $fillRemaining
FileSystem = $fileSystem
})
}
Update-AdditionalDataPartitionsListView -State $State
}
function Get-ConfigDriverBaseName {
param(
[string]$RawName
@@ -526,6 +834,9 @@ function Update-UIFromConfig {
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 'txtOSPartitionSizeGB' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OSPartitionSize' -TransformValue { param($val) Get-PartitionSizeGBDisplay -SizeBytes $val } -State $State
Set-UIValue -ControlName 'txtRecoveryPartitionSizeGB' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'RecoveryPartitionSize' -TransformValue { param($val) Get-PartitionSizeGBDisplay -SizeBytes $val } -State $State
Import-AdditionalDataPartitionsFromConfig -State $State -ConfigContent $ConfigContent
Set-UIValue -ControlName 'cmbSystemPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'SystemPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
Set-UIValue -ControlName 'cmbWindowsPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
Set-UIValue -ControlName 'cmbRecoveryPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'RecoveryPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
@@ -377,6 +377,9 @@ function Register-EventHandlers {
# List of TextBox controls that require integer-only input
$integerOnlyTextBoxes = @(
$State.Controls.txtDiskSize,
$State.Controls.txtOSPartitionSizeGB,
$State.Controls.txtRecoveryPartitionSizeGB,
$State.Controls.txtDataPartitionSizeGB,
$State.Controls.txtMemory,
$State.Controls.txtProcessors,
$State.Controls.txtThreads,
@@ -992,6 +995,52 @@ function Register-EventHandlers {
Update-VMNetworkingControls -State $localState
})
$State.Controls.chkDataPartitionFillRemaining.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.txtDataPartitionSizeGB.IsEnabled = $false
$localState.Controls.txtDataPartitionSizeGB.Clear()
})
$State.Controls.chkDataPartitionFillRemaining.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.txtDataPartitionSizeGB.IsEnabled = $true
})
$State.Controls.btnAddDataPartition.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
Add-AdditionalDataPartition -State $localState
})
$State.Controls.btnRemoveSelectedDataPartitions.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
Remove-SelectedDataPartition -State $localState
})
$State.Controls.btnClearDataPartitions.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
Clear-ListViewContent -State $localState `
-ListViewControl $localState.Controls.lstDataPartitions `
-BackingDataList $localState.Data.additionalDataPartitionsDataList `
-ConfirmationTitle "Clear Data Partitions" `
-ConfirmationMessage "Are you sure you want to clear all additional data partitions?" `
-StatusMessage "Additional data partitions list cleared." `
-TextBoxesToClear @($localState.Controls.txtDataPartitionName, $localState.Controls.txtDataPartitionSizeGB)
$localState.Controls.chkDataPartitionFillRemaining.IsChecked = $false
$localState.Controls.txtDataPartitionSizeGB.IsEnabled = $true
})
# Persist custom VM switch name when user edits it while 'Other' is selected
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
param($eventSource, $routedEventArgs)
@@ -258,6 +258,16 @@ function Initialize-UIControls {
$State.Controls.cmbSystemPartitionDriveLetter = $window.FindName('cmbSystemPartitionDriveLetter')
$State.Controls.cmbWindowsPartitionDriveLetter = $window.FindName('cmbWindowsPartitionDriveLetter')
$State.Controls.cmbRecoveryPartitionDriveLetter = $window.FindName('cmbRecoveryPartitionDriveLetter')
$State.Controls.txtOSPartitionSizeGB = $window.FindName('txtOSPartitionSizeGB')
$State.Controls.txtRecoveryPartitionSizeGB = $window.FindName('txtRecoveryPartitionSizeGB')
$State.Controls.txtDataPartitionName = $window.FindName('txtDataPartitionName')
$State.Controls.cmbDataPartitionDriveLetter = $window.FindName('cmbDataPartitionDriveLetter')
$State.Controls.txtDataPartitionSizeGB = $window.FindName('txtDataPartitionSizeGB')
$State.Controls.chkDataPartitionFillRemaining = $window.FindName('chkDataPartitionFillRemaining')
$State.Controls.btnAddDataPartition = $window.FindName('btnAddDataPartition')
$State.Controls.btnRemoveSelectedDataPartitions = $window.FindName('btnRemoveSelectedDataPartitions')
$State.Controls.btnClearDataPartitions = $window.FindName('btnClearDataPartitions')
$State.Controls.lstDataPartitions = $window.FindName('lstDataPartitions')
$State.Controls.cmbLogicalSectorSize = $window.FindName('cmbLogicalSectorSize')
$State.Controls.txtProductKey = $window.FindName('txtProductKey')
$State.Controls.txtOfficePath = $window.FindName('txtOfficePath')
@@ -813,6 +823,14 @@ function Initialize-DynamicUIElements {
# Keep BYO application columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
# Additional Data Partitions ListView setup
$State.Controls.lstDataPartitions.ItemsSource = $State.Data.additionalDataPartitionsDataList.ToArray()
$itemStyleDataPartitions = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleDataPartitions.BasedOn = $listViewItemBaseStyle }
$itemStyleDataPartitions.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstDataPartitions.ItemContainerStyle = $itemStyleDataPartitions
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDataPartitions
# Apps Script Variables ListView setup
# Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
@@ -1565,10 +1565,28 @@ if ($dismExitCode -ne 0) {
WriteLog 'Successfully applied FFU'
function Get-WindowsPartitionFromAppliedDisk {
param(
[Parameter(Mandatory)]
[int]$DiskNumber
)
$basicDataPartitions = @(Get-Partition -DiskNumber $DiskNumber | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' })
foreach ($partition in $basicDataPartitions) {
if ([string]::IsNullOrWhiteSpace($partition.DriveLetter)) { continue }
$volume = Get-Volume -DriveLetter $partition.DriveLetter -ErrorAction SilentlyContinue
if ($null -ne $volume -and $volume.FileSystemLabel -eq 'Windows') {
return $partition
}
}
return $basicDataPartitions | Select-Object -First 1
}
# Verify Windows partition exists and assign drive letter
$windowsPartition = Get-Partition -DiskNumber $DiskID | Where-Object { $_.PartitionNumber -eq 3 }
$windowsPartition = Get-WindowsPartitionFromAppliedDisk -DiskNumber $DiskID
if ($null -eq $windowsPartition) {
$errorMessage = "Windows partition (Partition 3) not found after applying FFU, even though DISM reported success."
$errorMessage = "Windows partition not found after applying FFU, even though DISM reported success."
WriteLog $errorMessage
Stop-Script -Message $errorMessage
}
@@ -1585,7 +1603,7 @@ if ($null -eq $windowsVolume) {
}
WriteLog "Successfully assigned drive letter 'W'."
$recoveryPartition = Get-Partition -DiskNumber $DiskID | Where-Object PartitionNumber -eq 4
$recoveryPartition = Get-Partition -DiskNumber $DiskID | Where-Object Type -eq Recovery | Select-Object -First 1
if ($recoveryPartition) {
WriteLog 'Setting recovery partition attributes'
$diskpartScript = @(
@@ -1602,12 +1620,17 @@ if ($recoveryPartition) {
$WinRE = $USBDrive + "WinRE\winre.wim"
If (Test-Path -Path $WinRE) {
WriteLog 'Copying modified WinRE to Recovery directory'
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Set-Partition -NewDriveLetter R
if ($null -eq $recoveryPartition) {
$errorMessage = 'Recovery partition not found after applying FFU. Cannot copy modified WinRE.'
WriteLog $errorMessage
Stop-Script -Message $errorMessage
}
Set-Partition -InputObject $recoveryPartition -NewDriveLetter R
Invoke-Process xcopy.exe "/h $WinRE R:\Recovery\WindowsRE\ /Y"
WriteLog 'Copying WinRE to Recovery directory succeeded'
WriteLog 'Registering location of recovery tools'
Invoke-Process W:\Windows\System32\Reagentc.exe "/Setreimage /Path R:\Recovery\WindowsRE /Target W:\Windows"
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Remove-PartitionAccessPath -AccessPath R:
Remove-PartitionAccessPath -InputObject $recoveryPartition -AccessPath R:
WriteLog 'Registering location of recovery tools succeeded'
}
#Autopilot JSON
Binary file not shown.
+7 -5
View File
@@ -16,7 +16,9 @@ Bring Your Own Applications allows you to run any command line you want in the v
All applications are stored in the `$AppsPath` parent folder which defaults to `C:\FFUDevelopment\Apps`. Winget source applications and BYO Apps that you select Copy Apps are stored in `$AppsPath\Win32`. MSStore source apps from Winget are stored in `$AppsPath\MSStore`.
At build time, an `Apps.iso` file is created of the `$AppsPath` folder. This ISO gets mounted to the VM. It shows up in the VM as the `D:\` drive. When creating your command line or arguments, you must make sure to reference `D:\`.
At build time, an `Apps.iso` file is created from the `$AppsPath` folder and mounted to the VM. Use `%FFUAppsRoot%` in command lines and arguments to reference the mounted Apps ISO, for example `%FFUAppsRoot%\Win32\MyApp\setup.exe`.
Existing `UserAppList.json` files that use `D:\` paths are still supported. If a legacy `D:\` path does not exist in the VM but the same relative path exists on the Apps ISO, FFU Builder remaps it to `%FFUAppsRoot%` at runtime.
## Name
@@ -24,7 +26,7 @@ The name of the application. The name is also used when selecting **Copy Apps**
## Command Line
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath` this should start with `D:\` (e.g. `D:\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`)
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath`, use `%FFUAppsRoot%` (e.g. `%FFUAppsRoot%\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`). Legacy `D:\Win32` paths are still supported.
For MSI applications, this should only include msiexec. The rest of the command line will be specified in arguments.
@@ -32,7 +34,7 @@ For MSI applications, this should only include msiexec. The rest of the command
These are the command line arguments for the application. Using the Mozilla Firefox example above, the arguments would be `/S /PreventRebootRequired=true`.
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "D:\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "%FFUAppsRoot%\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
## Source
@@ -58,7 +60,7 @@ Below is the `UserAppList.json` of Chrome and Firefox using the example above.
"Priority": 1,
"Name": "Google Chrome",
"CommandLine": "msiexec",
"Arguments": "/i \"D:\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
"Arguments": "/i \"%FFUAppsRoot%\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
"Source": "C:\\temp\\source\\Google Chrome",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
@@ -66,7 +68,7 @@ Below is the `UserAppList.json` of Chrome and Firefox using the example above.
{
"Priority": 2,
"Name": "Mozilla Firefox",
"CommandLine": "D:\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
"CommandLine": "%FFUAppsRoot%\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
"Arguments": "/S /PreventRebootRequired=true",
"Source": "C:\\temp\\source\\Mozilla Firefox",
"AdditionalExitCodes": "",
+13 -1
View File
@@ -55,7 +55,19 @@ Drive letter used for the Windows partition while building the FFU VHDX. Default
Drive letter used for the Recovery partition while building the FFU VHDX. Default is `R`.
These settings only affect FFU creation. They do not change the hard-coded drive letters used by `ApplyFFU.ps1` during deployment.
These settings only affect FFU creation. The deployment script discovers the Windows and Recovery partitions from the applied disk instead of relying on fixed partition numbers.
## Additional Data Partitions
Creates optional data partitions after the Windows Recovery partition. This keeps Recovery immediately after Windows, which leaves the normal WinRE layout intact while still allowing one or more data volumes in the captured FFU.
Set **Windows Partition Size (GB)** before adding data partitions. Without an explicit Windows partition size, Windows would consume the remaining VHDX space and there would be no room left for Recovery and data partitions.
Use **Recovery Partition Size (GB)** only when you need a fixed Recovery partition size. Leave it blank to let the build calculate the Recovery size from `winre.wim` plus buffer space.
Each data partition has a name, build-time drive letter, and either a size in GB or **Fill Remaining**. Only one data partition can use **Fill Remaining**. Fixed-size data partitions are created before the fill-remaining data partition so the fill-remaining volume does not consume space reserved for later fixed-size volumes.
The Apps ISO drive letter is discovered at runtime. If you create a data partition that uses `D:`, application installs should use `%FFUAppsRoot%` for Apps ISO paths. Legacy `D:\` paths in `UserAppList.json` are still supported when they point to files on the Apps ISO.
## Logical Sector Size
+4
View File
@@ -19,6 +19,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| 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. |
| -AdditionalDataPartitions | object[] | Additional Data Partitions | Creates optional data partitions after the Recovery partition. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, and FileSystem. Only one item can use FillRemaining. Fixed-size data partitions are created before the fill-remaining data partition. |
| -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. |
@@ -69,6 +70,8 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -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. |
| -OptimizeFFUPartitionNumber | int | CLI/config only | Optional partition number to pass to DISM /Optimize-FFU /PartitionNumber. Leave as 0 to optimize with DISM defaults. |
| -OSPartitionSize | uint64 | Windows Partition Size (GB) | Fixed size of the Windows partition in bytes. Required when -AdditionalDataPartitions is configured so the VHDX has room for Recovery and data partitions. The UI stores this from the GB value. |
| -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. |
@@ -76,6 +79,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| -ProductKey | string | Product Key | Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here. |
| -PromptExternalHardDiskMedia | bool | Prompt for External Hard Disk Media | When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. |
| -RecoveryPartitionDriveLetter | string | Recovery Partition Drive Letter | Drive letter used for the Recovery partition while building the FFU VHDX. Default is R. |
| -RecoveryPartitionSize | uint64 | Recovery Partition Size (GB) | Optional fixed size of the Recovery partition in bytes. Leave unset or 0 to let the build calculate the Recovery partition size from winre.wim plus buffer space. The UI stores this from the GB value. |
| -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. |