mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-08-12 21:53:05 -06:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "D:\Win32\Mozilla firefox\setup.msi" /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 "%FFUAppsRoot%\Win32\Mozilla firefox\setup.msi" /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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user