- 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
+251
View File
@@ -0,0 +1,251 @@
# FFU.Common.Core.psm1
# Contains common core functions like logging and process invocation.
#Requires -Modules BitsTransfer
# Script-scoped variable for the log file path
$script:CommonCoreLogFilePath = $null
# Mutex for log file access
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
# Function to set the log file path for this module
function Set-CommonCoreLogPath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$script:CommonCoreLogFilePath = $Path
if (-not [string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
# This initial WriteLog confirms the path is set and the logger is working.
WriteLog "CommonCoreLogPath set to: $script:CommonCoreLogFilePath"
}
else {
# This Write-Warning will appear on console if path is bad, but won't go to log file yet.
Write-Warning "Set-CommonCoreLogPath called with an empty or null path."
}
}
# Centralized WriteLog function
function WriteLog {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LogText
)
# Check if the log file path has been set
if ([string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
Write-Warning "CommonCoreLogFilePath not set. Message: $LogText"
return
}
$logEntry = "$((Get-Date).ToString()) $LogText"
$streamWriter = $null
try {
$script:commonCoreLogMutex.WaitOne() | Out-Null
# Ensure directory exists before writing
$logDir = Split-Path -Path $script:CommonCoreLogFilePath -Parent
if (-not (Test-Path -Path $logDir -PathType Container)) {
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
}
$streamWriter = New-Object System.IO.StreamWriter($script:CommonCoreLogFilePath, $true, [System.Text.Encoding]::UTF8)
$streamWriter.WriteLine($logEntry)
Write-Verbose $LogText
}
catch {
# Use Write-Host for console visibility as Write-Warning might also try to log
Write-Host "WARNING: Error writing to log file '$($script:CommonCoreLogFilePath)': $($_.Exception.Message)" -ForegroundColor Yellow
}
finally {
if ($null -ne $streamWriter) {
$streamWriter.Dispose()
}
$script:commonCoreLogMutex.ReleaseMutex()
}
}
# Function to invoke external process
# function Invoke-Process {
# [CmdletBinding(SupportsShouldProcess)]
# param(
# [Parameter(Mandatory)]
# [ValidateNotNullOrEmpty()]
# [string]$FilePath,
# [Parameter()]
# [ValidateNotNullOrEmpty()]
# [string[]]$ArgumentList,
# [Parameter()]
# [ValidateNotNullOrEmpty()]
# [bool]$Wait = $true
# )
# $ErrorActionPreference = 'Stop' # Keep this local to the function
# 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
# }
# # DEBUG
# # WriteLog "Running Command: $($startProcessParams.FilePath) $($startProcessParams.ArgumentList -join ' ')"
# if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
# $cmd = Start-Process @startProcessParams
# $cmdOutput = Get-Content -Path $stdOutTempFile -Raw -ErrorAction SilentlyContinue
# $cmdError = Get-Content -Path $stdErrTempFile -Raw -ErrorAction SilentlyContinue
# if (-not [string]::IsNullOrWhiteSpace($cmdOutput)) {
# WriteLog "STDOUT from '$FilePath': $cmdOutput"
# }
# if (-not [string]::IsNullOrWhiteSpace($cmdError)) {
# WriteLog "STDERR from '$FilePath': $cmdError"
# }
# if ($cmd.ExitCode -ne 0 -and $Wait) {
# $errorMessage = "Process '$FilePath' exited with code $($cmd.ExitCode)."
# if (-not [string]::IsNullOrWhiteSpace($cmdError)) {
# $errorMessage += " Error: $cmdError"
# }
# elseif (-not [string]::IsNullOrWhiteSpace($cmdOutput)) {
# $errorMessage += " Output: $cmdOutput"
# }
# throw $errorMessage.Trim()
# }
# }
# }
# catch {
# WriteLog "Error in Invoke-Process for '$FilePath': $($_.Exception.Message)"
# throw
# }
# finally {
# if (Test-Path $stdOutTempFile) { Remove-Item -Path $stdOutTempFile -Force -ErrorAction Ignore }
# if (Test-Path $stdErrTempFile) { Remove-Item -Path $stdErrTempFile -Force -ErrorAction Ignore }
# }
# return $cmd
# }
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
}
}
}
}
catch {
#$PSCmdlet.ThrowTerminatingError($_)
WriteLog $_
# Write-Host "Script failed - $Logfile for more info"
throw $_
}
finally {
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
}
return $cmd
}
# Function to download a file using BITS with retry and error handling
function Start-BitsTransferWithRetry {
param (
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination,
[int]$Retries = 3
)
$attempt = 0
$lastError = $null
while ($attempt -lt $Retries) {
$OriginalVerbosePreference = $VerbosePreference
$OriginalProgressPreference = $ProgressPreference
try {
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop
$ProgressPreference = $OriginalProgressPreference
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Successfully transferred $Source to $Destination."
return
}
catch {
$lastError = $_
$attempt++
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $($lastError.Exception.Message)."
Start-Sleep -Seconds (1 * $attempt)
}
finally {
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
$ProgressPreference = $OriginalProgressPreference
}
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
$VerbosePreference = $OriginalVerbosePreference
}
}
}
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
throw $lastError
}
Export-ModuleMember -Function Set-CommonCoreLogPath, WriteLog, Invoke-Process, Start-BitsTransferWithRetry
@@ -0,0 +1,92 @@
# FFU Common Drivers Module
# Contains shared functions related to driver handling.
#Requires -Modules Dism
# Import the common core module for logging and process invocation
Import-Module "$PSScriptRoot\FFU.Common.Core.psm1"
# --------------------------------------------------------------------------
# SECTION: Driver Compression Function
# --------------------------------------------------------------------------
function Compress-DriverFolderToWim {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[ValidateScript({ Test-Path -Path $_ -PathType Container })]
[string]$SourceFolderPath,
[Parameter(Mandatory = $true)]
[string]$DestinationWimPath,
[Parameter()]
[string]$WimName, # Optional, defaults to folder name
[Parameter()]
[string]$WimDescription # Optional, defaults to folder name
)
WriteLog "Starting compression of folder '$SourceFolderPath' to '$DestinationWimPath'."
# Default WIM Name and Description to the source folder name if not provided
$sourceFolderName = Split-Path -Path $SourceFolderPath -Leaf
if ([string]::IsNullOrWhiteSpace($WimName)) {
$WimName = $sourceFolderName
WriteLog "WIM Name not provided, defaulting to source folder name: '$WimName'."
}
if ([string]::IsNullOrWhiteSpace($WimDescription)) {
$WimDescription = $sourceFolderName
WriteLog "WIM Description not provided, defaulting to source folder name: '$WimDescription'."
}
# Ensure destination directory exists
$destinationDir = Split-Path -Path $DestinationWimPath -Parent
if (-not (Test-Path -Path $destinationDir -PathType Container)) {
WriteLog "Creating destination directory: $destinationDir"
try {
New-Item -Path $destinationDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
catch {
WriteLog "Failed to create destination directory '$destinationDir': $($_.Exception.Message)"
return $false # Indicate failure
}
}
if ($PSCmdlet.ShouldProcess("Folder '$SourceFolderPath'", "Compress to WIM '$DestinationWimPath'")) {
try {
# Construct arguments for dism.exe
$dismArgs = "/Capture-Image /ImageFile:`"$DestinationWimPath`" /CaptureDir:`"$SourceFolderPath`" /Name:`"$WimName`" /Description:`"$WimDescription`" /Compress:Max /CheckIntegrity /Quiet"
WriteLog "Executing dism.exe via Invoke-Process with arguments:"
WriteLog "dism.exe $dismArgs"
# Call Invoke-Process (assumed to be available from FFUUI.Core.psm1 or another imported module)
# Invoke-Process is expected to throw an exception for non-zero exit codes.
Invoke-Process -FilePath "dism.exe" -ArgumentList $dismArgs -Wait $true
WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe."
return $true # Indicate success
}
catch {
WriteLog "Failed to compress folder '$SourceFolderPath' to WIM '$DestinationWimPath' using dism.exe."
WriteLog "Error details: $($_.Exception.Message)"
# Check if the error message contains details about the DISM log (dism.exe output might be in the exception)
if ($_.Exception.Message -match 'DISM log file can be found at (.*)') {
$dismLogPath = $matches[1].Trim()
WriteLog "Check the DISM log for more details: $dismLogPath"
}
return $false # Indicate failure
}
}
else {
WriteLog "Compression operation skipped due to -WhatIf."
return $false # Indicate skipped operation
}
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
Export-ModuleMember -Function Compress-DriverFolderToWim
@@ -1,3 +1,6 @@
# Import the common core module for logging
Import-Module "$PSScriptRoot\FFU.Common.Core.psm1"
function Get-Application {
[CmdletBinding()]
param (
@@ -14,10 +17,10 @@ function Get-Application {
$wingetSearchResult = Find-WinGetPackage -id $AppId -MatchOption Equals -Source $Source
if (-not $wingetSearchResult) {
if ($VerbosePreference -ne 'Continue') {
Write-Error "$AppName not found in $Source repository. Exiting."
Write-Error "$AppName not found in $Source repository."
Write-Error "Check the AppList.json file and make sure the AppID is correct."
}
WriteLog "$AppName not found in $Source repository. Exiting."
WriteLog "$AppName not found in $Source repository."
WriteLog "Check the AppList.json file and make sure the AppID is correct."
Exit 1
}
@@ -26,7 +29,8 @@ function Get-Application {
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
if ($Source -eq 'winget' -or $appIsWin32) {
$appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
} else {
}
else {
$appFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
}
@@ -67,14 +71,12 @@ function Get-Application {
Write-Error "ERROR: The Microsoft Store app $AppName does not support downloads by the publisher. Please remove it from the AppList.json. If there's a winget source version of the application, try using that instead. Exiting."
Exit 1
}
# Other download failures
WriteLog "ERROR: Download failed for $AppName with status: $($wingetDownloadResult.status)"
WriteLog "ERROR: Download failed for $AppName with error code: $($wingetDownloadResult.ExtendedErrorCode)"
WriteLog "Exiting"
}
else {
$errormsg = "ERROR: Download failed for $AppName with status: $($wingetDownloadResult.status) $($wingetDownloadResult.ExtendedErrorCode)"
WriteLog $errormsg
Remove-Item -Path $appFolderPath -Recurse -Force
Write-Error "ERROR: Download failed for $AppName with status: $($wingetDownloadResult.status)"
Write-Error "ERROR: Download failed for $AppName with error code: $($wingetDownloadResult.ExtendedErrorCode)"
Write-Error $errormsg
Exit 1
}
}
@@ -88,7 +90,7 @@ function Get-Application {
if ($uwpExtensions -contains $installerPath.Extension -and $appFolderPath -match 'Win32') {
# Handle UWP apps
$NewAppPath = "$AppsPath\MSStore\$AppName"
Writelog "$AppName is a UWP app. Moving to $NewAppPath"
WriteLog "$AppName is a UWP app. Moving to $NewAppPath"
WriteLog "Creating $NewAppPath"
New-Item -Path "$AppsPath\MSStore\$AppName" -ItemType Directory -Force | Out-Null
WriteLog "Moving $AppName to $NewAppPath"
@@ -97,12 +99,16 @@ function Get-Application {
Remove-Item -Path $appFolderPath -Force -Recurse
WriteLog "$AppName moved to $NewAppPath"
# Set-InstallStoreAppsFlag
$result = 0 # Success for UWP app
}
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
elseif($appFolderPath -match 'Win32'){
elseif ($appFolderPath -match 'Win32') {
WriteLog "$AppName is a Win32 app. Adding silent install command to $orchestrationpath\WinGetWin32Apps.json"
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath
}
else {
# For any other case, set result to 0 (success)
$result = 0
}
# Handle MSStore specific post-processing
@@ -145,8 +151,9 @@ function Get-Application {
}
}
}
return $result
}
function Get-Apps {
[CmdletBinding()]
param (
@@ -181,7 +188,7 @@ function Get-Apps {
# Create necessary folders
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
# Process WinGet apps
if ($wingetApps) {
if (-not (Test-Path -Path $win32Folder -PathType Container)) {
@@ -218,7 +225,30 @@ function Get-Apps {
}
}
}
function Install-WinGet {
param (
[string]$Architecture
)
$packages = @(
@{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx" },
@{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx" },
@{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" }
)
foreach ($package in $packages) {
$destination = Join-Path -Path $env:TEMP -ChildPath $package.File
WriteLog "Downloading $($package.Name) from $($package.Url) to $destination"
Start-BitsTransferWithRetry -Source $package.Url -Destination $destination
WriteLog "Installing $($package.Name)..."
# Don't show progress bar for Add-AppxPackage - there's a weird issue where the progress stays on the screen after the apps are installed
$ProgressPreference = 'SilentlyContinue'
Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue
# Set progress preference back to default
$ProgressPreference = 'Continue'
WriteLog "Removing $($package.Name)..."
Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue
}
WriteLog "WinGet installation complete."
}
function Confirm-WinGetInstallation {
[CmdletBinding()]
param()
@@ -228,19 +258,20 @@ function Confirm-WinGetInstallation {
# Check WinGet PowerShell module
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue
if ($wingetModule.Version -lt $minVersion -or -not $wingetModule) {
$wingetModuleVersion = [version]$wingetModule.Version
if ($wingetModuleVersion -lt $minVersion -or -not $wingetModule) {
WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...'
# Handle PSGallery trust settings
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
if($PSGalleryTrust -eq 'Untrusted') {
if ($PSGalleryTrust -eq 'Untrusted') {
WriteLog 'Temporarily setting PSGallery as a trusted repository...'
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
}
Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery'
if($PSGalleryTrust -eq 'Untrusted') {
if ($PSGalleryTrust -eq 'Untrusted') {
WriteLog 'Setting PSGallery back to untrusted repository...'
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
WriteLog 'Done'
@@ -264,7 +295,6 @@ function Confirm-WinGetInstallation {
WriteLog "Installed WinGet version: $wingetVersion"
}
}
function Add-Win32SilentInstallCommand {
param (
[string]$AppFolder,
@@ -275,7 +305,7 @@ function Add-Win32SilentInstallCommand {
if (-not $installerPath) {
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
Remove-Item -Path $AppFolderPath -Recurse -Force
return $false
return 1
}
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
$yamlContent = Get-Content -Path $yamlFile -Raw
@@ -283,7 +313,7 @@ function Add-Win32SilentInstallCommand {
if (-not $silentInstallSwitch) {
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
Remove-Item -Path $appFolderPath -Recurse -Force
return $false
return 2
}
$installer = Split-Path -Path $installerPath -Leaf
if ($installerPath.Extension -eq ".exe") {
@@ -305,17 +335,18 @@ function Add-Win32SilentInstallCommand {
if ($appsData.Count -gt 0) {
$highestPriority = $appsData.Count + 1
}
} else {
}
else {
$appsData = @()
$highestPriority = 1
}
# Create new app entry
$newApp = [PSCustomObject]@{
Priority = $highestPriority
Name = $appName
Priority = $highestPriority
Name = $appName
CommandLine = $silentInstallCommand
Arguments = $silentInstallSwitch
Arguments = $silentInstallSwitch
}
$appsData += $newApp
@@ -323,5 +354,13 @@ function Add-Win32SilentInstallCommand {
WriteLog "Added $appName to WinGetWin32Apps.json with priority $highestPriority"
# return $true
}
# Return 0 for success
return 0
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
# Export functions needed by both BuildFFUVM and the UI Core module
Export-ModuleMember -Function Get-Application, Get-Apps, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget