- Added Apps\Orchestration folder with new orchestration workflow to replace InstallAppsAndSysprep.cmd file.

- Updated BuildFFUUnattend files to point to the new Orchestrator.ps1 file.
- Added new common and FFUUI.Core directories that house common/shared files between the UI and PS1 script. This breaks up each of the PS1 scripts to keep things smaller and more organized. Still a lot of work to do here to pull some stuff out of the PS1 scripts.
- Modified the CaptureFFU.ps1 file to include more info during the capture process to help with troubleshooting
- Too many functional changes to list here.
This commit is contained in:
rbalsleyMSFT
2025-05-24 15:14:46 -07:00
parent 2efb9fb2a1
commit f162de89be
17 changed files with 7720 additions and 2944 deletions
@@ -0,0 +1,4 @@
{
"VMWareTools": true,
"foo": "bar"
}
@@ -0,0 +1,56 @@
$basePath = "D:\MSStore"
# Check if the base path exists
Write-Host "Installing Store Apps: Checking for $basePath"
if (-not (Test-Path -Path $basePath)) {
Write-Host "Installing Store Apps: $basePath does not exist."
exit
}
Write-Host "Installing Store Apps: $basePath exists, installing apps."
# Process each app folder in the base path
foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
$folderPath = $appFolder.FullName
$dependenciesFolder = Join-Path -Path $folderPath -ChildPath "Dependencies"
# Find main package - exclude Dependencies folder items and xml/yaml files
$mainPackage = Get-ChildItem -Path $folderPath -File |
Where-Object {
$_.DirectoryName -ne $dependenciesFolder -and
$_.Extension -ne ".xml" -and
$_.Extension -ne ".yaml"
} | Select-Object -First 1
if ($mainPackage) {
# Build DISM command with main package
$dismParams = @(
"/Online"
"/Add-ProvisionedAppxPackage"
"/PackagePath:`"$($mainPackage.FullName)`""
"/Region:all"
"/StubPackageOption:installfull"
)
# Add dependency packages if they exist
if (Test-Path -Path $dependenciesFolder) {
$dependencies = Get-ChildItem -Path $dependenciesFolder -File
foreach ($dependency in $dependencies) {
$dismParams += "/DependencyPackagePath:`"$($dependency.FullName)`""
}
}
# Look for license file and add appropriate parameter
$licenseFile = Get-ChildItem -Path $folderPath -Filter "*.xml" -File | Select-Object -First 1
if ($licenseFile) {
$dismParams += "/LicensePath:`"$($licenseFile.FullName)`""
} else {
$dismParams += "/SkipLicense"
}
# Construct final command
$dismCommand = "DISM " + ($dismParams -join " ")
# Output and execute the command
Write-Output $dismCommand
Invoke-Expression -Command $dismCommand
}
}
@@ -0,0 +1,176 @@
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$FilePath,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string[]]$ArgumentList,
[Parameter()]
[ValidateNotNullOrEmpty()]
[bool]$Wait = $true
)
$ErrorActionPreference = 'Stop'
try {
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
$startProcessParams = @{
FilePath = $FilePath
ArgumentList = $ArgumentList
RedirectStandardError = $stdErrTempFile
RedirectStandardOutput = $stdOutTempFile
Wait = $($Wait);
PassThru = $true;
NoNewWindow = $true;
}
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
$cmd = Start-Process @startProcessParams
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
$cmdError = Get-Content -Path $stdErrTempFile -Raw
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
if ($cmdError) {
throw $cmdError.Trim()
}
if ($cmdOutput) {
throw $cmdOutput.Trim()
}
}
else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
# WriteLog $cmdOutput
Write-Host $cmdOutput
}
}
}
}
catch {
#$PSCmdlet.ThrowTerminatingError($_)
# WriteLog $_
# Write-Host "Script failed - $Logfile for more info"
throw $_
}
finally {
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
}
return $cmd
}
# Define paths for the JSON files
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
# Initialize an empty array to hold all apps
$allApps = @()
# Read the WinGetWin32Apps.json file if it exists
if (Test-Path -Path $wingetAppsJsonFile) {
Write-Host "Processing WinGetWin32Apps.json..."
try {
$wingetApps = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($wingetApps -is [array]) {
$allApps += $wingetApps
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
} elseif ($wingetApps) {
$allApps += @($wingetApps) # Ensure it's added as an array element
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: $_"
# Decide if execution should stop or continue
# exit 1
}
} else {
Write-Host "WinGetWin32Apps.json file not found. Skipping."
}
# Read the UserAppList.json file if it exists
if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..."
try {
$userApps = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userApps -is [array]) {
$allApps += $userApps
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
} elseif ($userApps) {
$allApps += @($userApps) # Ensure it's added as an array element
Write-Host "Found 1 user-defined app."
} else {
Write-Host "UserAppList.json is empty or invalid."
}
} catch {
Write-Error "Failed to read or parse UserAppList.json file: $_"
# Decide if execution should stop or continue
# exit 1
}
} else {
Write-Host "UserAppList.json file not found. Skipping."
}
# Check if there are any apps to install
if ($allApps.Count -eq 0) {
Write-Host "No Win32 apps found in either WinGetWin32Apps.json or UserAppList.json. Exiting."
exit 0
}
Write-Host "Total apps to install: $($allApps.Count)"
# Sort all apps by priority
$sortedApps = $allApps | 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
}
}
try {
# Construct the argument list properly, handling potential array vs string
$argumentsToPass = if ($app.Arguments -is [array]) { $app.Arguments } else { @($app.Arguments) }
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
$result = Invoke-Process -FilePath $($app.CommandLine) -ArgumentList $argumentsToPass
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
} catch {
Write-Error "Error occurred while installing $($app.Name): $_"
# Decide if execution should stop or continue after an error
# exit 1
}
}
Write-Host "All Win32 app installations attempted."
@@ -0,0 +1,59 @@
<#
.SYNOPSIS
This script uses the variables from the AppsScriptVariables hashtable passed to BuildFFUVM.ps1 to run application deployment tasks.
.DESCRIPTION
By defining the variables in the AppsScriptVariables hashtable, you can customize the application deployment tasks that are run by this script.
The BuildFFUVM.ps1 script will export the AppsScriptVariables hashtable to a JSON file in the Orchestration folder.
Include your own custom script here if you want to run it as part of the application deployment tasks.
Alternatively, you can pass the AppsScriptVariables hashtable directly to this script.
#>
param (
[hashtable]$AppsScriptVariables
)
# Try to read from the JSON file if it exists and AppsScriptVariables is not provided
$appsScriptVarsJsonPath = Join-Path -Path $PSScriptRoot -ChildPath "AppsScriptVariables.json"
if ((-not $AppsScriptVariables -or $AppsScriptVariables.Count -eq 0) -and (Test-Path -Path $appsScriptVarsJsonPath)) {
try {
$jsonContent = Get-Content -Path $appsScriptVarsJsonPath -Raw -ErrorAction Stop
$jsonObject = $jsonContent | ConvertFrom-Json -ErrorAction Stop
# Convert PSCustomObject to hashtable
$AppsScriptVariables = @{}
foreach ($prop in $jsonObject.PSObject.Properties) {
$AppsScriptVariables[$prop.Name] = $prop.Value
}
Write-Host "Successfully loaded AppsScriptVariables from $appsScriptVarsJsonPath"
}
catch {
Write-Error "Failed to load AppsScriptVariables from JSON file: $_"
}
}
else {
Write-Host "AppsScriptVariables provided directly, skipping JSON file load."
}
# Example of how to use the AppsScriptVariables hashtable to control script execution
# Example: Check if a variable named 'foo' is set to string 'true' and run a script accordingly
# if ($AppsScriptVariables['foo'] -eq 'true') {
# Write-Host "Foo would have installed"
# }
# else {
# Write-Host "Foo would not have installed"
# }
# Example: Check if a variable named 'foo' is set to boolean $true and run a script accordingly
# if ($AppsScriptVariables['foo'] -eq $true) {
# Write-Host "Foo would have been installed"
# }
# else {
# Write-Host "Foo would not have installed"
# }
# Your code below here
Write-Host 'Invoke-AppsScript.ps1 finished'
@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Orchestration script for FFU VM deployment tasks
.DESCRIPTION
This script orchestrates the following deployment tasks:
- Install-Office.ps1
- Update-Defender.ps1
- Update-MSRT.ps1
- Update-OneDrive.ps1
- Update-Edge.ps1
- Install-Win32Apps.ps1
- Invoke-AppsScript.ps1
- Install-UserApps.ps1
- Install-StoreApps.ps1
- Run-DiskCleanup.ps1
- Run-Sysprep.ps1
The script will check for the presence of each of these files and if they exist, will run the script
#>
# Header
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " FFU Builder Orchestrator " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Define the path to the scripts
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
# Define the list of scripts to run, order doesn't matter - if you have a custom script, add it here
$scriptList = @(
"Install-Office.ps1",
"Update-Defender.ps1",
"Update-MSRT.ps1",
"Update-OneDrive.ps1",
"Update-Edge.ps1",
"Install-Win32Apps.ps1",
"Install-StoreApps.ps1",
"Invoke-AppsScript.ps1",
"Install-UserApps.ps1"
)
# Check if each script exists and run it if it does
foreach ($script in $scriptList) {
$scriptFile = Join-Path -Path $scriptPath -ChildPath $script
if (Test-Path -Path $scriptFile) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script" -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
# pause
& $scriptFile
}
}
# Run-DiskCleanup.ps1 must run before Run-Sysprep.ps1
$diskCleanupScript = Join-Path -Path $scriptPath -ChildPath "Run-DiskCleanup.ps1"
if (Test-Path -Path $diskCleanupScript) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: Run-DiskCleanup.ps1" -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
& $diskCleanupScript
} else {
Write-Host "Run-DiskCleanup.ps1 not found!"
}
# Run-Sysprep.ps1 must run last
$sysprepScript = Join-Path -Path $scriptPath -ChildPath "Run-Sysprep.ps1"
if (Test-Path -Path $sysprepScript) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: Run-Sysprep.ps1" -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
& $sysprepScript
} else {
Write-Host "Run-Sysprep.ps1 not found!"
}
@@ -0,0 +1,21 @@
# Run disk cleanup (cleanmgr.exe) with all options enabled
# Reference: https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/automating-disk-cleanup-tool
$rootKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches"
# Set StateFlags0000 to 2 for all subkeys except "Offline Pages Files"
Get-ChildItem -Path $rootKey | ForEach-Object {
if ($_.PSChildName -ne "Offline Pages Files") {
Set-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Type DWord -Value 2 -Force
}
}
# Run the disk cleanup tool with the specified flags
Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:0" -Wait
# Remove the StateFlags0000 registry values that were added
Get-ChildItem -Path $rootKey | ForEach-Object {
if ($_.PSChildName -ne "Offline Pages Files") {
Remove-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Force
}
}
@@ -0,0 +1,14 @@
#The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
#Also kills the sysprep process in order to automate sysprep generalize
# Convert these commands to native powershell
# del c:\windows\panther\unattend\unattend.xml /F /Q
# del c:\windows\panther\unattend.xml /F /Q
# taskkill /IM sysprep.exe
# timeout /t 10
# & c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
Remove-Item -Path "C:\windows\panther\unattend\unattend.xml" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "C:\windows\panther\unattend.xml" -Force -ErrorAction SilentlyContinue
Stop-Process -Name "sysprep" -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 10
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe