mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
Add FFU.Common and FFUUI.Core module manifests and shared UI functions
- Created module manifest for FFU.Common with initial version 0.0.1. - Created module manifest for FFUUI.Core with initial version 0.0.1. - Implemented shared UI functions in FFUUI.Shared.psm1, including: - Update-ListViewItemStatus: Updates the status of items in a ListView. - Update-OverallProgress: Updates a progress bar and status label. - Invoke-ProgressUpdate: Enqueues progress updates to the UI thread. - Add-SortableColumn: Adds sortable columns to a ListView. - Add-SelectableGridViewColumn: Adds a selectable column with a "Select All" checkbox. - Update-SelectAllHeaderCheckBoxState: Updates the state of the header checkbox. - Invoke-ListViewSort: Sorts ListView items based on specified properties. - Show-ModernFolderPicker: Displays a modern folder picker dialog.
This commit is contained in:
@@ -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 *
|
||||
@@ -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
|
||||
@@ -0,0 +1,478 @@
|
||||
function Invoke-ParallelProcessing {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[array]$ItemsToProcess,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[object]$ListViewControl = $null, # Changed type to [object]
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$IdentifierProperty = 'Identifier',
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$StatusProperty = 'Status',
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('WingetDownload', 'CopyBYO', 'DownloadDriverByMake')]
|
||||
[string]$TaskType,
|
||||
[Parameter()]
|
||||
[hashtable]$TaskArguments = @{},
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$CompletedStatusText = "Completed",
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ErrorStatusPrefix = "Error: ",
|
||||
[Parameter(Mandatory = $false)]
|
||||
[object]$WindowObject = $null, # Changed type to [object]
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MainThreadLogPath = $null # New parameter for the log path
|
||||
)
|
||||
# Check if running in UI mode by verifying the types of the passed objects
|
||||
$isUiMode = ($null -ne $WindowObject -and $WindowObject -is [System.Windows.Window] -and $null -ne $ListViewControl -and $ListViewControl -is [System.Windows.Controls.ListView])
|
||||
|
||||
if ($isUiMode) {
|
||||
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items in ListView '$($ListViewControl.Name)'."
|
||||
}
|
||||
else {
|
||||
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items (non-UI mode)."
|
||||
}
|
||||
$resultsCollection = [System.Collections.Generic.List[object]]::new()
|
||||
$jobs = @()
|
||||
$results = @() # Store results from jobs
|
||||
$totalItems = $ItemsToProcess.Count
|
||||
$processedCount = 0
|
||||
|
||||
# Create a thread-safe queue for intermediate progress updates
|
||||
$progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable]
|
||||
|
||||
# Define common paths locally within this function's scope
|
||||
$coreModulePath = $MyInvocation.MyCommand.Module.Path
|
||||
$coreModuleDirectory = Split-Path -Path $coreModulePath -Parent
|
||||
$ffuDevelopmentRoot = Split-Path -Path $coreModuleDirectory -Parent
|
||||
|
||||
# Paths to the module DIRECTORIES needed by the parallel threads
|
||||
$commonModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFU.Common"
|
||||
$uiCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFUUI.Core"
|
||||
|
||||
# Use the explicitly passed MainThreadLogPath for the parallel jobs.
|
||||
# If not provided (e.g., older calls or direct module use without this param), it might be null.
|
||||
# The parallel job's Set-CommonCoreLogPath will handle null/empty paths by warning.
|
||||
$currentLogFilePathForJob = $MainThreadLogPath
|
||||
|
||||
$jobScopeVariables = $TaskArguments.Clone()
|
||||
$jobScopeVariables['_commonModulePath'] = $commonModulePathForJob
|
||||
$jobScopeVariables['_uiCoreModulePath'] = $uiCoreModulePathForJob
|
||||
$jobScopeVariables['_currentLogFilePathForJob'] = $currentLogFilePathForJob # Pass the determined log path
|
||||
$jobScopeVariables['_progressQueue'] = $progressQueue
|
||||
|
||||
# The $TaskScriptBlock parameter is already a local variable in this scope
|
||||
|
||||
# Initial UI update needs to happen *before* starting the jobs
|
||||
# Update all items to a static "Processing..." status
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
foreach ($item in $ItemsToProcess) {
|
||||
$identifierValue = $item.$IdentifierProperty
|
||||
$initialStaticStatus = "Queued..."
|
||||
try {
|
||||
# Update the UI on the main thread to show the item is being queued for processing
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifierValue -StatusProperty $StatusProperty -StatusValue $initialStaticStatus
|
||||
})
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting initial status for item '$identifierValue': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Queue items and start jobs using the pipeline and $using:
|
||||
try {
|
||||
# $jobScopeVariables and $TaskType are local here
|
||||
# Inside the -Parallel scriptblock, we access them with $using:
|
||||
$jobs = $ItemsToProcess | ForEach-Object -Parallel {
|
||||
# Access the current item via pipeline variable $_
|
||||
$currentItem = $_
|
||||
# Access the combined arguments hashtable from the calling scope using $using:
|
||||
$localJobArgs = $using:jobScopeVariables
|
||||
# Access the task type string from the calling scope using $using:
|
||||
$localTaskType = $using:TaskType
|
||||
# Access the progress queue using $using:
|
||||
$localProgressQueue = $localJobArgs['_progressQueue']
|
||||
|
||||
# Initialize result hashtable
|
||||
$taskResult = $null
|
||||
$resultIdentifier = $null
|
||||
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||
$resultCode = 1 # Default to error
|
||||
|
||||
try {
|
||||
# Import modules needed for the task
|
||||
Import-Module $localJobArgs['_commonModulePath'] -Force
|
||||
Import-Module $localJobArgs['_uiCoreModulePath'] -Force
|
||||
|
||||
# Set the log path for this parallel thread
|
||||
Set-CommonCoreLogPath -Path $localJobArgs['_currentLogFilePathForJob']
|
||||
|
||||
# Set other global variables if tasks rely on them (prefer passing as parameters)
|
||||
$global:AppsPath = $localJobArgs['AppsPath']
|
||||
$global:WindowsArch = $localJobArgs['WindowsArch']
|
||||
if ($localJobArgs.ContainsKey('OrchestrationPath')) {
|
||||
$global:OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
}
|
||||
|
||||
# Execute the appropriate background task based on $localTaskType
|
||||
switch ($localTaskType) {
|
||||
'WingetDownload' {
|
||||
# Pass the progress queue to the task function
|
||||
$taskResult = Start-WingetAppDownloadTask -ApplicationItemData $currentItem `
|
||||
-AppListJsonPath $localJobArgs['AppListJsonPath'] `
|
||||
-AppsPath $localJobArgs['AppsPath'] `
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-OrchestrationPath $localJobArgs['OrchestrationPath'] `
|
||||
-ProgressQueue $localProgressQueue
|
||||
if ($null -ne $taskResult) {
|
||||
$resultIdentifier = $taskResult.Id
|
||||
$resultStatus = $taskResult.Status
|
||||
$resultCode = $taskResult.ResultCode
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = $currentItem.Id # Fallback
|
||||
$resultStatus = "Error: WingetDownload task returned null"
|
||||
$resultCode = 1
|
||||
WriteLog $resultStatus
|
||||
}
|
||||
}
|
||||
'CopyBYO' {
|
||||
# Pass the progress queue to the task function
|
||||
$taskResult = Start-CopyBYOApplicationTask -ApplicationItemData $currentItem `
|
||||
-AppsPath $localJobArgs['AppsPath'] `
|
||||
-ProgressQueue $localProgressQueue
|
||||
if ($null -ne $taskResult) {
|
||||
$resultIdentifier = $taskResult.Name
|
||||
$resultStatus = $taskResult.Status
|
||||
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = $currentItem.Name # Fallback
|
||||
$resultStatus = "Error: CopyBYO task returned null"
|
||||
$resultCode = 1
|
||||
WriteLog $resultStatus
|
||||
}
|
||||
}
|
||||
'DownloadDriverByMake' {
|
||||
$make = $currentItem.Make
|
||||
# Ensure $resultIdentifier is set before the switch, using the main IdentifierProperty
|
||||
# This is crucial if a Make is unsupported or a task fails to return a result.
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
|
||||
switch ($make) {
|
||||
'Microsoft' {
|
||||
$taskResult = Save-MicrosoftDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
}
|
||||
'Dell' {
|
||||
# DellCatalogXmlPath might be null if catalog prep failed; Save-DellDriversTask should handle this.
|
||||
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-DellCatalogXmlPath $localJobArgs['DellCatalogXmlPath'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
}
|
||||
'HP' {
|
||||
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-WindowsVersion $localJobArgs['WindowsVersion'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
}
|
||||
'Lenovo' {
|
||||
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
|
||||
-DriversFolder $localJobArgs['DriversFolder'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
}
|
||||
default {
|
||||
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
|
||||
WriteLog $unsupportedMakeMessage
|
||||
$resultStatus = $unsupportedMakeMessage
|
||||
$resultCode = 1
|
||||
# $resultIdentifier is already set from $currentItem.$($using:IdentifierProperty)
|
||||
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||
# $taskResult remains null, handled below
|
||||
}
|
||||
}
|
||||
|
||||
# Consolidate result handling for 'DownloadDriverByMake'
|
||||
if ($null -ne $taskResult) {
|
||||
# $resultIdentifier is already $currentItem.$($using:IdentifierProperty)
|
||||
# We use the task's returned Model/Identifier for logging/status if needed,
|
||||
# but the primary identifier for UI updates should be consistent.
|
||||
$taskSpecificIdentifier = $null
|
||||
if ($taskResult.PSObject.Properties.Name -contains 'Model') { $taskSpecificIdentifier = $taskResult.Model }
|
||||
elseif ($taskResult.PSObject.Properties.Name -contains 'Identifier') { $taskSpecificIdentifier = $taskResult.Identifier }
|
||||
|
||||
$resultStatus = $taskResult.Status
|
||||
if ($taskResult.PSObject.Properties.Name -contains 'Success') {
|
||||
# Dell, Microsoft, Lenovo
|
||||
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
|
||||
}
|
||||
elseif ($taskResult.Status -like 'Completed*') {
|
||||
# HP success
|
||||
$resultCode = 0
|
||||
}
|
||||
elseif ($taskResult.Status -like 'Error*') {
|
||||
# HP error
|
||||
$resultCode = 1
|
||||
}
|
||||
else {
|
||||
# Default for HP if status is unexpected, or if 'Success' property is missing but status isn't 'Completed*' or 'Error*'
|
||||
WriteLog "Unexpected status or missing 'Success' property from task for '$taskSpecificIdentifier': $($taskResult.Status)"
|
||||
$resultCode = 1 # Assume error
|
||||
}
|
||||
}
|
||||
elseif ($make -in ('Microsoft', 'Dell', 'HP', 'Lenovo')) {
|
||||
# This means a specific Make case was hit, but $taskResult was unexpectedly null
|
||||
$nullTaskResultMessage = "Error: Task for Make '$make' returned null."
|
||||
WriteLog $nullTaskResultMessage
|
||||
$resultStatus = $nullTaskResultMessage
|
||||
$resultCode = 1
|
||||
# $resultIdentifier is already set
|
||||
}
|
||||
# If it was an unsupported Make, $resultStatus and $resultCode are already set from the 'default' case.
|
||||
}
|
||||
Default {
|
||||
# This handles unknown $localTaskType values
|
||||
$resultStatus = "Error: Task type '$localTaskType' not recognized"
|
||||
$resultCode = 1
|
||||
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = "UnknownItem"
|
||||
}
|
||||
WriteLog "Error in parallel job: Unknown TaskType '$localTaskType' provided for item '$resultIdentifier'."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Catch errors within the parallel task execution
|
||||
$resultStatus = "Error: $($_.Exception.Message)"
|
||||
$resultCode = 1
|
||||
# Try to get an identifier
|
||||
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
|
||||
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
|
||||
}
|
||||
else {
|
||||
$resultIdentifier = "UnknownItemOnError"
|
||||
}
|
||||
WriteLog "Exception during parallel task '$localTaskType' for item '$resultIdentifier': $($_.Exception.ToString())"
|
||||
# Enqueue the error status from the catch block
|
||||
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
|
||||
}
|
||||
|
||||
# Return a consistent hashtable structure (final result)
|
||||
return @{
|
||||
Identifier = $resultIdentifier
|
||||
Status = $resultStatus # Return the final status
|
||||
ResultCode = $resultCode
|
||||
}
|
||||
|
||||
} -ThrottleLimit 5 -AsJob
|
||||
}
|
||||
catch {
|
||||
# Catch errors during the *creation* of the parallel jobs (e.g., module loading in main thread failed)
|
||||
WriteLog "Error initiating ForEach-Object -Parallel: $($_.Exception.Message)"
|
||||
# Update all items to show a general startup error
|
||||
$errorStatus = "$ErrorStatusPrefix Failed to start processing"
|
||||
foreach ($item in $ItemsToProcess) {
|
||||
$identifier = $item.$IdentifierProperty
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { # Use $WindowObject
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifier -StatusProperty $StatusProperty -StatusValue $errorStatus # Pass $WindowObject
|
||||
})
|
||||
}
|
||||
# Exit the function as processing cannot proceed
|
||||
return
|
||||
}
|
||||
|
||||
# Check if any jobs failed to start immediately (e.g., module loading issues within the job)
|
||||
$failedJobs = $jobs | Where-Object { $_.State -eq 'Failed' -and $_.JobStateInfo.Reason }
|
||||
foreach ($failedJob in $failedJobs) {
|
||||
WriteLog "Job $($failedJob.Id) failed to start or failed early: $($failedJob.JobStateInfo.Reason)"
|
||||
# We don't easily know which item failed here without more complex mapping
|
||||
# Update overall status maybe?
|
||||
$processedCount++
|
||||
}
|
||||
# Filter out jobs that failed immediately
|
||||
$jobs = $jobs | Where-Object { $_.State -ne 'Failed' }
|
||||
|
||||
# Process job results and intermediate status updates without blocking the UI thread
|
||||
while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty) {
|
||||
# Continue while jobs are running OR queue has messages
|
||||
|
||||
# 1. Process intermediate status updates from the queue
|
||||
$statusUpdate = $null
|
||||
while ($progressQueue.TryDequeue([ref]$statusUpdate)) {
|
||||
if ($null -ne $statusUpdate) {
|
||||
$intermediateIdentifier = $statusUpdate.Identifier
|
||||
$intermediateStatus = $statusUpdate.Status
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
# Update the UI with the intermediate status
|
||||
try {
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus
|
||||
})
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting intermediate status for item '$intermediateIdentifier': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Log intermediate status if not in UI mode
|
||||
WriteLog "Intermediate Status for '$intermediateIdentifier': $intermediateStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Check for completed jobs
|
||||
$completedJobs = $jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }
|
||||
|
||||
if ($completedJobs) {
|
||||
foreach ($completedJob in $completedJobs) {
|
||||
$finalIdentifier = "UnknownJob" # Placeholder if we can't get result
|
||||
$finalStatus = "$ErrorStatusPrefix Job $($completedJob.Id) ended unexpectedly"
|
||||
$finalResultCode = 1 # Assume error
|
||||
|
||||
if ($completedJob.State -eq 'Failed') {
|
||||
WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)"
|
||||
# Try to get identifier from job name if possible (less reliable)
|
||||
# $finalIdentifier = ... logic to parse job name or map ID ...
|
||||
$finalStatus = "$ErrorStatusPrefix Job Failed"
|
||||
$processedCount++ # Count failed job as processed
|
||||
}
|
||||
elseif ($completedJob.HasMoreData) {
|
||||
# Receive final results specifically from the completed job
|
||||
$jobResults = $completedJob | Receive-Job
|
||||
foreach ($result in $jobResults) {
|
||||
# Should only be one result per job in this setup
|
||||
if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) {
|
||||
$finalIdentifier = $result.Identifier
|
||||
$status = $result.Status # This is the FINAL status returned by the task
|
||||
$finalResultCode = $result.ResultCode
|
||||
|
||||
# Determine final status text based on the result code
|
||||
if ($finalResultCode -eq 0) {
|
||||
# Assuming 0 means success
|
||||
# Use the specific status returned by the successful job
|
||||
# This handles cases like "Already downloaded" correctly
|
||||
$finalStatus = $status
|
||||
}
|
||||
else {
|
||||
$finalStatus = "$($ErrorStatusPrefix)$($status)" # Use status from result for error message
|
||||
}
|
||||
$processedCount++
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)"
|
||||
$finalStatus = "$ErrorStatusPrefix Invalid Result Format"
|
||||
$processedCount++ # Count as processed to avoid loop issues
|
||||
}
|
||||
# Add the received result (even if format was unexpected, for logging)
|
||||
if ($null -ne $result) { $resultsCollection.Add($result) }
|
||||
break # Only process first result from this job
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Job completed but had no data
|
||||
if ($completedJob.State -ne 'Failed') {
|
||||
WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data."
|
||||
# $finalIdentifier = ... logic to parse job name or map ID ...
|
||||
$finalStatus = "$ErrorStatusPrefix No Result Data"
|
||||
$processedCount++
|
||||
}
|
||||
# If it was 'Failed', it was handled above
|
||||
}
|
||||
|
||||
# Update the specific item in the ListView with its FINAL status
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
try {
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus
|
||||
})
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Update overall progress after processing a job's results
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
|
||||
})
|
||||
}
|
||||
else {
|
||||
# Log final status if not in UI mode
|
||||
WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)"
|
||||
}
|
||||
|
||||
# Remove the completed/failed job from the list and clean it up
|
||||
$jobs = $jobs | Where-Object { $_.Id -ne $completedJob.Id }
|
||||
Remove-Job -Job $completedJob -Force -ErrorAction SilentlyContinue
|
||||
} # End foreach completedJob
|
||||
} # End if ($completedJobs)
|
||||
|
||||
# 3. Allow UI events to process and sleep briefly
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
# Only sleep if jobs are still running AND the queue is empty (to avoid delaying UI updates)
|
||||
if ($jobs.Count -gt 0 -and $progressQueue.IsEmpty) {
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
elseif (-not $progressQueue.IsEmpty) {
|
||||
# If queue has messages, process them immediately without sleeping
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Non-UI mode, just sleep if jobs are running
|
||||
if ($jobs.Count -gt 0) {
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
# If jobs are done AND queue is empty, the loop condition will terminate
|
||||
|
||||
} # End while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty)
|
||||
|
||||
# Final cleanup of any remaining jobs (shouldn't be necessary with this loop logic, but good practice)
|
||||
if ($jobs.Count -gt 0) {
|
||||
WriteLog "Cleaning up $($jobs.Count) remaining jobs after loop exit."
|
||||
Remove-Job -Job $jobs -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($isUiMode) {
|
||||
# Use the new $isUiMode flag
|
||||
WriteLog "Invoke-ParallelProcessing finished for ListView '$($ListViewControl.Name)'."
|
||||
# Final overall progress update
|
||||
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
|
||||
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processing complete. Processed $processedCount of $totalItems." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
|
||||
})
|
||||
}
|
||||
else {
|
||||
WriteLog "Invoke-ParallelProcessing finished (non-UI mode). Processed $processedCount of $totalItems."
|
||||
}
|
||||
|
||||
# Return all collected final results from jobs
|
||||
return $resultsCollection
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-ParallelProcessing
|
||||
@@ -0,0 +1,366 @@
|
||||
# # Import the common core module for logging
|
||||
# Import-Module "$PSScriptRoot\FFU.Common.Core.psm1"
|
||||
|
||||
function Get-Application {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('winget', 'msstore')]
|
||||
[string]$Source
|
||||
)
|
||||
|
||||
# Validate app exists in repository
|
||||
$wingetSearchResult = Find-WinGetPackage -id $AppId -MatchOption Equals -Source $Source
|
||||
if (-not $wingetSearchResult) {
|
||||
if ($VerbosePreference -ne 'Continue') {
|
||||
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."
|
||||
WriteLog "Check the AppList.json file and make sure the AppID is correct."
|
||||
Exit 1
|
||||
}
|
||||
|
||||
# Determine app type and folder path
|
||||
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
||||
if ($Source -eq 'winget' -or $appIsWin32) {
|
||||
$appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||
}
|
||||
else {
|
||||
$appFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
||||
}
|
||||
|
||||
# Create app folder
|
||||
New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null
|
||||
|
||||
# Log download information
|
||||
WriteLog "Downloading $AppName for $WindowsArch architecture..."
|
||||
if ($Source -eq 'msstore') {
|
||||
WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.'
|
||||
}
|
||||
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source"
|
||||
|
||||
# Download the app
|
||||
$wingetDownloadResult = Export-WinGetPackage -id $AppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source
|
||||
|
||||
# Handle download status
|
||||
if ($wingetDownloadResult.status -ne 'Ok') {
|
||||
# Try downloading without architecture if no applicable installer found
|
||||
if ($wingetDownloadResult.status -eq 'NoApplicableInstallers' -or $wingetDownloadResult.status -eq 'NoApplicableInstallerFound') {
|
||||
WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..."
|
||||
$wingetDownloadResult = Export-WinGetPackage -id $AppId -DownloadDirectory $appFolderPath -Source $Source
|
||||
if ($wingetDownloadResult.status -eq 'Ok') {
|
||||
WriteLog "Downloaded $AppName without specifying architecture."
|
||||
}
|
||||
else {
|
||||
WriteLog "ERROR: No installer found for $AppName. Exiting"
|
||||
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
# Handle Store-specific errors
|
||||
elseif ($Source -eq 'msstore') {
|
||||
# If download not supported by publisher
|
||||
if ($wingetDownloadResult.ExtendedErrorCode -match '0x8A150084') {
|
||||
WriteLog "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."
|
||||
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||
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
|
||||
}
|
||||
}
|
||||
else {
|
||||
$errormsg = "ERROR: Download failed for $AppName with status: $($wingetDownloadResult.status) $($wingetDownloadResult.ExtendedErrorCode)"
|
||||
WriteLog $errormsg
|
||||
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||
Write-Error $errormsg
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "$AppName downloaded to $appFolderPath"
|
||||
|
||||
# Handle winget source apps that have appx, appxbundle, msix, or msixbundle extensions but were downloaded to the Win32 folder
|
||||
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction Stop
|
||||
$uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle")
|
||||
|
||||
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 "Creating $NewAppPath"
|
||||
New-Item -Path "$AppsPath\MSStore\$AppName" -ItemType Directory -Force | Out-Null
|
||||
WriteLog "Moving $AppName to $NewAppPath"
|
||||
Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$AppName" -Force
|
||||
WriteLog "Removing $appFolderPath"
|
||||
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') {
|
||||
WriteLog "$AppName is a Win32 app. Adding silent install command to $orchestrationpath\WinGetWin32Apps.json"
|
||||
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath
|
||||
}
|
||||
else {
|
||||
# For any other case, set result to 0 (success)
|
||||
$result = 0
|
||||
}
|
||||
|
||||
# Handle MSStore specific post-processing
|
||||
if ($Source -eq 'msstore' -and $appFolderPath -match 'MSStore') {
|
||||
# Set-InstallStoreAppsFlag
|
||||
|
||||
# Handle ARM64-specific dependencies
|
||||
if ($WindowsArch -eq 'ARM64') {
|
||||
WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.'
|
||||
$dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue
|
||||
if ($dependencies) {
|
||||
foreach ($dependency in $dependencies) {
|
||||
if ($dependency.Name -notmatch 'ARM64') {
|
||||
WriteLog "Removing dependency file $($dependency.FullName)"
|
||||
Remove-Item -Path $dependency.FullName -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up multiple versions (keep only the latest)
|
||||
WriteLog "$AppName has completed downloading. Identifying the latest version of $AppName."
|
||||
$packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop
|
||||
|
||||
# Find latest version based on signature date
|
||||
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
|
||||
# Remove older versions
|
||||
WriteLog "Latest version of $AppName has been identified as $latestPackage. Removing old versions of $AppName that may have downloaded."
|
||||
foreach ($package in $packages) {
|
||||
if ($package.FullName -ne $latestPackage) {
|
||||
try {
|
||||
WriteLog "Removing $($package.FullName)"
|
||||
Remove-Item -Path $package.FullName -Force
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to delete: $($package.FullName) - $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
function Get-Apps {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppList
|
||||
)
|
||||
|
||||
# Load and validate app list
|
||||
$apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json
|
||||
if (-not $apps) {
|
||||
WriteLog "No apps were specified in AppList.json file."
|
||||
return
|
||||
}
|
||||
|
||||
# Process WinGet apps
|
||||
$wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" }
|
||||
if ($wingetApps) {
|
||||
WriteLog 'Winget apps to be installed:'
|
||||
$wingetApps | ForEach-Object { WriteLog $_.Name }
|
||||
}
|
||||
|
||||
# Process Store apps
|
||||
$StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
|
||||
if ($StoreApps) {
|
||||
WriteLog 'Store apps to be installed:'
|
||||
$StoreApps | ForEach-Object { WriteLog $_.Name }
|
||||
}
|
||||
|
||||
# Ensure WinGet is available
|
||||
Confirm-WinGetInstallation
|
||||
|
||||
# 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)) {
|
||||
WriteLog "Creating folder for Winget Win32 apps: $win32Folder"
|
||||
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
||||
WriteLog "Folder created successfully."
|
||||
}
|
||||
|
||||
foreach ($wingetApp in $wingetApps) {
|
||||
try {
|
||||
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget'
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Process Store apps
|
||||
if ($StoreApps) {
|
||||
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
foreach ($storeApp in $StoreApps) {
|
||||
try {
|
||||
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore'
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($storeApp.Name): $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
WriteLog 'Checking if WinGet is installed...'
|
||||
$minVersion = [version]"1.8.1911"
|
||||
|
||||
# Check WinGet PowerShell module
|
||||
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue
|
||||
$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') {
|
||||
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') {
|
||||
WriteLog 'Setting PSGallery back to untrusted repository...'
|
||||
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
|
||||
WriteLog 'Done'
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Installed Microsoft.Winget.Client module version: $($wingetModule.Version)"
|
||||
}
|
||||
|
||||
# Check WinGet CLI
|
||||
$wingetVersion = Get-WinGetVersion
|
||||
if (-not $wingetVersion) {
|
||||
WriteLog "WinGet is not installed. Installing WinGet..."
|
||||
Install-WinGet -Architecture $WindowsArch
|
||||
}
|
||||
elseif ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) {
|
||||
WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..."
|
||||
Install-WinGet -Architecture $WindowsArch
|
||||
}
|
||||
else {
|
||||
WriteLog "Installed WinGet version: $wingetVersion"
|
||||
}
|
||||
}
|
||||
function Add-Win32SilentInstallCommand {
|
||||
param (
|
||||
[string]$AppFolder,
|
||||
[string]$AppFolderPath
|
||||
)
|
||||
$appName = $AppFolder
|
||||
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop
|
||||
if (-not $installerPath) {
|
||||
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
|
||||
Remove-Item -Path $AppFolderPath -Recurse -Force
|
||||
return 1
|
||||
}
|
||||
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
|
||||
$yamlContent = Get-Content -Path $yamlFile -Raw
|
||||
$silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim()
|
||||
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 2
|
||||
}
|
||||
$installer = Split-Path -Path $installerPath -Leaf
|
||||
if ($installerPath.Extension -eq ".exe") {
|
||||
$silentInstallCommand = "D:\win32\$appFolder\$installer"
|
||||
}
|
||||
elseif ($installerPath.Extension -eq ".msi") {
|
||||
$silentInstallCommand = "msiexec"
|
||||
$silentInstallSwitch = "/i `"D:\win32\$appFolder\$installer`" $silentInstallSwitch"
|
||||
}
|
||||
|
||||
# Path to the JSON file
|
||||
$wingetWin32AppsJson = "$orchestrationPath\WinGetWin32Apps.json"
|
||||
|
||||
# Initialize or load existing JSON data
|
||||
if (Test-Path -Path $wingetWin32AppsJson) {
|
||||
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
|
||||
|
||||
# Get highest priority value
|
||||
if ($appsData.Count -gt 0) {
|
||||
$highestPriority = $appsData.Count + 1
|
||||
}
|
||||
}
|
||||
else {
|
||||
$appsData = @()
|
||||
$highestPriority = 1
|
||||
}
|
||||
|
||||
# Create new app entry
|
||||
$newApp = [PSCustomObject]@{
|
||||
Priority = $highestPriority
|
||||
Name = $appName
|
||||
CommandLine = $silentInstallCommand
|
||||
Arguments = $silentInstallSwitch
|
||||
}
|
||||
|
||||
$appsData += $newApp
|
||||
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
|
||||
|
||||
WriteLog "Added $appName to WinGetWin32Apps.json with priority $highestPriority"
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,134 @@
|
||||
#
|
||||
# Module manifest for module 'FFU.Common'
|
||||
#
|
||||
# Generated by: Richard Balsley
|
||||
#
|
||||
# Generated on: 6/11/2025
|
||||
#
|
||||
|
||||
@{
|
||||
|
||||
# Script module or binary module file associated with this manifest.
|
||||
RootModule = 'FFU.Common.Core.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '0.0.1'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
# ID used to uniquely identify this module
|
||||
GUID = '7dac2b8f-e65a-4997-961e-7a5ef5161901'
|
||||
|
||||
# Author of this module
|
||||
Author = 'Richard Balsley'
|
||||
|
||||
# Company or vendor of this module
|
||||
CompanyName = 'Unknown'
|
||||
|
||||
# Copyright statement for this module
|
||||
Copyright = '(c) Richard Balsley. All rights reserved.'
|
||||
|
||||
# Description of the functionality provided by this module
|
||||
Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM.ps1 build script.'
|
||||
|
||||
# Minimum version of the PowerShell engine required by this module
|
||||
# PowerShellVersion = ''
|
||||
|
||||
# Name of the PowerShell host required by this module
|
||||
# PowerShellHostName = ''
|
||||
|
||||
# Minimum version of the PowerShell host required by this module
|
||||
# PowerShellHostVersion = ''
|
||||
|
||||
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# DotNetFrameworkVersion = ''
|
||||
|
||||
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
|
||||
# ClrVersion = ''
|
||||
|
||||
# Processor architecture (None, X86, Amd64) required by this module
|
||||
# ProcessorArchitecture = ''
|
||||
|
||||
# Modules that must be imported into the global environment prior to importing this module
|
||||
# RequiredModules = @()
|
||||
|
||||
# Assemblies that must be loaded prior to importing this module
|
||||
# RequiredAssemblies = @()
|
||||
|
||||
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
|
||||
# ScriptsToProcess = @()
|
||||
|
||||
# Type files (.ps1xml) to be loaded when importing this module
|
||||
# TypesToProcess = @()
|
||||
|
||||
# Format files (.ps1xml) to be loaded when importing this module
|
||||
# FormatsToProcess = @()
|
||||
|
||||
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||
NestedModules = @('FFU.Common.Drivers.psm1',
|
||||
'FFU.Common.Winget.psm1',
|
||||
'FFU.Common.Parallel.psm1')
|
||||
|
||||
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
|
||||
FunctionsToExport = '*'
|
||||
|
||||
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
|
||||
CmdletsToExport = '*'
|
||||
|
||||
# Variables to export from this module
|
||||
VariablesToExport = '*'
|
||||
|
||||
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
|
||||
AliasesToExport = '*'
|
||||
|
||||
# DSC resources to export from this module
|
||||
# DscResourcesToExport = @()
|
||||
|
||||
# List of all modules packaged with this module
|
||||
# ModuleList = @()
|
||||
|
||||
# List of all files packaged with this module
|
||||
# FileList = @()
|
||||
|
||||
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
|
||||
PrivateData = @{
|
||||
|
||||
PSData = @{
|
||||
|
||||
# Tags applied to this module. These help with module discovery in online galleries.
|
||||
# Tags = @()
|
||||
|
||||
# A URL to the license for this module.
|
||||
# LicenseUri = ''
|
||||
|
||||
# A URL to the main website for this project.
|
||||
# ProjectUri = ''
|
||||
|
||||
# A URL to an icon representing this module.
|
||||
# IconUri = ''
|
||||
|
||||
# ReleaseNotes of this module
|
||||
# ReleaseNotes = ''
|
||||
|
||||
# Prerelease string of this module
|
||||
# Prerelease = ''
|
||||
|
||||
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
|
||||
# RequireLicenseAcceptance = $false
|
||||
|
||||
# External dependent modules of this module
|
||||
# ExternalModuleDependencies = @()
|
||||
|
||||
} # End of PSData hashtable
|
||||
|
||||
} # End of PrivateData hashtable
|
||||
|
||||
# HelpInfo URI of this module
|
||||
# HelpInfoURI = ''
|
||||
|
||||
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
|
||||
# DefaultCommandPrefix = ''
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user