mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-08-24 03:16:03 -06:00
a025812404
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.
426 lines
15 KiB
PowerShell
426 lines
15 KiB
PowerShell
# Allow Orchestrator.ps1 to override the app list file paths while preserving legacy defaults.
|
|
param(
|
|
[Parameter()]
|
|
[string]$wingetAppsJsonFile = (Join-Path -Path $PSScriptRoot -ChildPath "WinGetWin32Apps.json"),
|
|
[Parameter()]
|
|
[string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json")
|
|
)
|
|
|
|
function 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
|
|
(
|
|
[Parameter(Mandatory)]
|
|
[ValidateNotNullOrEmpty()]
|
|
[string]$FilePath,
|
|
|
|
[Parameter()]
|
|
[string[]]$ArgumentList,
|
|
|
|
[Parameter()]
|
|
[ValidateNotNullOrEmpty()]
|
|
[bool]$Wait = $true,
|
|
|
|
[Parameter()]
|
|
[string[]]$AdditionalSuccessCodes,
|
|
|
|
[Parameter()]
|
|
[bool]$IgnoreNonZeroExitCodes = $false
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
try {
|
|
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
|
# Use .NET Process class for proper stream handling
|
|
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
$pinfo.FileName = $FilePath
|
|
if ($ArgumentList) {
|
|
$pinfo.Arguments = $ArgumentList -join ' '
|
|
}
|
|
$pinfo.RedirectStandardOutput = $true
|
|
$pinfo.RedirectStandardError = $true
|
|
$pinfo.UseShellExecute = $false
|
|
$pinfo.CreateNoWindow = $true
|
|
|
|
$p = New-Object System.Diagnostics.Process
|
|
$p.StartInfo = $pinfo
|
|
|
|
# Start the process
|
|
$p.Start() | Out-Null
|
|
|
|
# Read output and error streams
|
|
$cmdOutput = $p.StandardOutput.ReadToEnd()
|
|
$cmdError = $p.StandardError.ReadToEnd()
|
|
|
|
if ($Wait) {
|
|
$p.WaitForExit()
|
|
}
|
|
|
|
$exitCode = $p.ExitCode
|
|
# An exit code of 0 is always a success
|
|
if ($exitCode -ne 0) {
|
|
# If IgnoreNonZeroExitCodes is true, treat any non-zero exit code as a success
|
|
if ($IgnoreNonZeroExitCodes) {
|
|
Write-Host "Ignoring non-zero exit code $exitCode because IgnoreNonZeroExitCodes is set to true."
|
|
}
|
|
# Check if the non-zero exit code is in the list of additional success codes
|
|
elseif ($null -eq $AdditionalSuccessCodes -or $exitCode -notin $AdditionalSuccessCodes) {
|
|
if ($cmdError) {
|
|
throw $cmdError.Trim()
|
|
}
|
|
if ($cmdOutput) {
|
|
throw $cmdOutput.Trim()
|
|
}
|
|
# If there's no output, throw a generic error with the exit code
|
|
if (-not $cmdError -and -not $cmdOutput) {
|
|
throw "Process exited with non-zero code: $exitCode"
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
|
# WriteLog $cmdOutput
|
|
}
|
|
}
|
|
|
|
# Create a simple object with exit code for compatibility
|
|
$result = [PSCustomObject]@{
|
|
ExitCode = $exitCode
|
|
}
|
|
|
|
return $result
|
|
}
|
|
}
|
|
catch {
|
|
#$PSCmdlet.ThrowTerminatingError($_)
|
|
# WriteLog $_
|
|
# Write-Host "Script failed - $Logfile for more info"
|
|
throw $_
|
|
}
|
|
}
|
|
|
|
function Format-MsiArguments {
|
|
<#
|
|
.SYNOPSIS
|
|
Ensures MSI file paths in msiexec arguments are properly quoted.
|
|
.DESCRIPTION
|
|
Detects /i arguments followed by an unquoted path ending in .msi
|
|
and wraps the path in double quotes to handle paths with spaces.
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string]$CommandLine,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string]$Arguments
|
|
)
|
|
|
|
# Only process if the command is msiexec
|
|
if ($CommandLine -notmatch '^msiexec(\.exe)?$') {
|
|
return $Arguments
|
|
}
|
|
|
|
# Regex pattern explanation:
|
|
# (?i) - Case-insensitive matching
|
|
# (/i)\s+ - Match /i followed by whitespace
|
|
# (?!") - Negative lookahead: not already quoted
|
|
# (.+?\.msi) - Capture path ending in .msi (lazy match to stop at first .msi)
|
|
# (?=\s+/|\s*$) - Followed by another switch or end of string
|
|
|
|
# Pattern to match /i followed by an unquoted MSI path
|
|
$pattern = '(?i)(/i)\s+(?!")(.+?\.msi)(?=\s+/|\s*$)'
|
|
|
|
if ($Arguments -match $pattern) {
|
|
$originalArgs = $Arguments
|
|
# Replace with quoted path
|
|
$Arguments = $Arguments -replace $pattern, '$1 "$2"'
|
|
Write-Host "Detected unquoted MSI path in msiexec arguments. Adjusted arguments:"
|
|
Write-Host "Original: $originalArgs"
|
|
Write-Host "Modified: $Arguments"
|
|
}
|
|
|
|
return $Arguments
|
|
}
|
|
|
|
function Install-Applications {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[array]$apps
|
|
)
|
|
|
|
if ($apps.Count -eq 0) {
|
|
Write-Host "No applications to install from this source."
|
|
return
|
|
}
|
|
|
|
Write-Host "Total apps to install from this source: $($apps.Count)"
|
|
|
|
# Sort all apps by priority
|
|
$sortedApps = $apps | Sort-Object -Property Priority
|
|
|
|
# Install each app
|
|
foreach ($app in $sortedApps) {
|
|
# Check if required properties exist
|
|
if (-not $app.PSObject.Properties['Name'] -or -not $app.PSObject.Properties['CommandLine'] -or -not $app.PSObject.Properties['Arguments']) {
|
|
Write-Warning "Skipping app due to missing required properties (Name, CommandLine, Arguments): $($app | ConvertTo-Json -Depth 1 -Compress)"
|
|
continue
|
|
}
|
|
|
|
Write-Host "Installing $($app.Name)..."
|
|
|
|
# Wait until no MSIExec installation is running
|
|
while ($true) {
|
|
try {
|
|
# Try to open the MSIExec global mutex
|
|
$Mutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
|
|
# Dispose releases the handle from our script only.
|
|
$Mutex.Dispose()
|
|
Write-Host "Another MSIExec installer is running. Waiting for 5 seconds before rechecking..."
|
|
Start-Sleep -Seconds 5
|
|
}
|
|
catch [System.Threading.WaitHandleCannotBeOpenedException] {
|
|
# If we can't open the mutex, it means no MSIExec installation is running
|
|
break
|
|
}
|
|
catch {
|
|
# Handle other potential errors when checking the mutex
|
|
Write-Warning "Error checking MSIExec mutex: $_. Proceeding with caution."
|
|
break
|
|
}
|
|
}
|
|
|
|
# Check for 'PAUSE' command
|
|
if ($app.CommandLine -eq 'PAUSE') {
|
|
Write-Host "Pausing script as requested by '$($app.Name)'. Press Enter to continue..."
|
|
$null = Read-Host
|
|
continue
|
|
}
|
|
|
|
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) { Resolve-AppsMediaPathValue -Value $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
|
|
if ($trimmed.Count -gt 0) {
|
|
$argumentsToPass = $trimmed
|
|
}
|
|
}
|
|
else {
|
|
$single = Resolve-AppsMediaPathValue -Value $app.Arguments.ToString().Trim()
|
|
if (-not [string]::IsNullOrWhiteSpace($single)) {
|
|
$argumentsToPass = @($single)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Check for and parse AdditionalExitCodes
|
|
$additionalSuccessCodes = @()
|
|
if ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) {
|
|
$additionalSuccessCodes = $app.AdditionalExitCodes -split ',' | ForEach-Object { $_.Trim() }
|
|
Write-Host "Additional success exit codes for $($app.Name): $($additionalSuccessCodes -join ', ')"
|
|
}
|
|
|
|
# Check for IgnoreNonZeroExitCodes
|
|
$ignoreNonZeroExitCodes = $false
|
|
if ($app.PSObject.Properties['IgnoreNonZeroExitCodes'] -and $app.IgnoreNonZeroExitCodes -is [bool]) {
|
|
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes
|
|
}
|
|
|
|
# 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 $commandLineToRun -Arguments $joinedArgs
|
|
if ($formattedArgs -ne $joinedArgs) {
|
|
$argumentsToPass = @($formattedArgs)
|
|
}
|
|
}
|
|
|
|
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
|
|
Write-Host "Running command: $commandLineToRun (no arguments)"
|
|
$result = Invoke-Process -FilePath $commandLineToRun -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
|
}
|
|
else {
|
|
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"
|
|
}
|
|
catch {
|
|
Write-Error "Error occurred while installing $($app.Name): $_"
|
|
Read-Host "An error occurred, and the script cannot continue. Press Enter to exit."
|
|
throw $_
|
|
}
|
|
}
|
|
}
|
|
|
|
# Initialize empty arrays for apps from each source
|
|
$wingetApps = @()
|
|
$userApps = @()
|
|
|
|
# Read the WinGetWin32Apps.json file if it exists
|
|
if (Test-Path -Path $wingetAppsJsonFile) {
|
|
Write-Host "Processing WinGetWin32Apps.json..."
|
|
try {
|
|
$wingetContent = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
|
if ($wingetContent -is [array]) {
|
|
$wingetApps = $wingetContent
|
|
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
|
|
}
|
|
elseif ($wingetContent) {
|
|
$wingetApps = @($wingetContent) # Ensure it's an array
|
|
Write-Host "Found 1 WinGet Win32 app."
|
|
}
|
|
else {
|
|
Write-Host "WinGetWin32Apps.json is empty or invalid."
|
|
}
|
|
}
|
|
catch {
|
|
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
|
|
}
|
|
}
|
|
else {
|
|
Write-Host "WinGetWin32Apps.json file not found. Skipping."
|
|
}
|
|
|
|
# Install WinGet apps if any were found
|
|
if ($wingetApps.Count -gt 0) {
|
|
Install-Applications -apps $wingetApps
|
|
}
|
|
|
|
# Read the configured BYO app list file if it exists
|
|
if (Test-Path -Path $userAppsJsonFile) {
|
|
Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
|
|
try {
|
|
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
|
|
if ($userContent -is [array]) {
|
|
$userApps = $userContent
|
|
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
|
}
|
|
elseif ($userContent) {
|
|
$userApps = @($userContent)
|
|
Write-Host "Found 1 user-defined app."
|
|
}
|
|
else {
|
|
Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
|
|
}
|
|
}
|
|
catch {
|
|
Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
|
|
}
|
|
}
|
|
else {
|
|
Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping."
|
|
}
|
|
|
|
# Install User apps if any were found
|
|
if ($userApps.Count -gt 0) {
|
|
Install-Applications -apps $userApps
|
|
}
|
|
|
|
# Check if any apps were installed at all
|
|
if ($wingetApps.Count -eq 0 -and $userApps.Count -eq 0) {
|
|
Write-Host "No Win32 apps found in either WinGetWin32Apps.json or UserAppList.json. Exiting."
|
|
exit 0
|
|
}
|
|
|
|
Write-Host "All Win32 app installations attempted." |