<# .SYNOPSIS Provides core, shared functions for logging, process execution, and resilient file transfers used across the FFU project. .DESCRIPTION This module is a central component of the FFU project, offering a set of robust, reusable functions. It includes a centralized logging mechanism (WriteLog), a wrapper for running external processes with error handling (Invoke-Process), a retry-aware BITS transfer function for reliable downloads (Start-BitsTransferWithRetry), and a progress reporting helper. This module is designed to be imported by other scripts and modules within the project to ensure consistent behavior for common tasks. #> # 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) $script:BitsTransferPriority = 'Normal' if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) { $script:BitsTransferPriority = $env:FFU_BITS_PRIORITY } # 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." } } function Set-BitsTransferPriority { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('Foreground', 'High', 'Normal', 'Low')] [string]$Priority ) $script:BitsTransferPriority = $Priority try { Set-Item -Path Env:FFU_BITS_PRIORITY -Value $Priority -ErrorAction Stop } catch { WriteLog "Failed to set FFU_BITS_PRIORITY environment variable: $($_.Exception.Message)" } WriteLog "BITS transfer priority set to $Priority." } # 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 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 Get-RunManifestPathForDownloadTarget { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Destination ) try { $currentPath = Split-Path -Path $Destination -Parent if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null } while ($currentPath) { $manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json' if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { return $manifestPath } $parentPath = Split-Path -Path $currentPath -Parent if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) { break } $currentPath = $parentPath } } catch { WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)" } return $null } function Register-CurrentRunDownloadTarget { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Destination ) if ([string]::IsNullOrWhiteSpace($Destination)) { return } $manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination if ([string]::IsNullOrWhiteSpace($manifestPath)) { return } $mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex' $mutex = New-Object System.Threading.Mutex($false, $mutexName) try { $null = $mutex.WaitOne() $manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if ($null -eq $manifest) { return } if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) { Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @() } $downloadTargets = @($manifest.DownloadTargets) if ($Destination -notin $downloadTargets) { $downloadTargets += $Destination $manifest.DownloadTargets = $downloadTargets $manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 WriteLog "Registered current-run download target: $Destination" } } catch { WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)" } finally { try { $mutex.ReleaseMutex() | Out-Null } catch {} $mutex.Dispose() } } # 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, [ValidateSet('Foreground','High','Normal','Low')] [string]$Priority ) if ([string]::IsNullOrWhiteSpace($Priority)) { if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) { $Priority = $env:FFU_BITS_PRIORITY } elseif (-not [string]::IsNullOrWhiteSpace($script:BitsTransferPriority)) { $Priority = $script:BitsTransferPriority } else { $Priority = 'Normal' } } # Register destination so cancel cleanup can remove this run's downloaded files # even when file timestamps are inherited from the source. Register-CurrentRunDownloadTarget -Destination $Destination $attempt = 0 $lastError = $null $notLoggedOnHResult = [int]0x800704dd $fallbackTriggered = $false while ($attempt -lt $Retries -and -not $fallbackTriggered) { $OriginalVerbosePreference = $VerbosePreference $OriginalProgressPreference = $ProgressPreference try { $VerbosePreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue' Start-BitsTransfer -Source $Source -Destination $Destination -Priority $Priority -ErrorAction Stop $ProgressPreference = $OriginalProgressPreference $VerbosePreference = $OriginalVerbosePreference WriteLog "Successfully transferred $Source to $Destination." return } catch { $lastError = $_ $attempt++ $errorMessage = $lastError.Exception.Message WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $errorMessage." $hResult = $null if ($null -ne $lastError.Exception) { $hResult = $lastError.Exception.HResult } $needsHttpFallback = $false if ($hResult -eq $notLoggedOnHResult) { $needsHttpFallback = $true } elseif ($errorMessage -match '0x800704DD' -or $errorMessage -match 'not.*logged on to the network') { $needsHttpFallback = $true } if ($needsHttpFallback) { WriteLog "BITS cannot download $Source because the current session is not logged on to the network. Falling back to Invoke-WebRequest." $fallbackTriggered = $true break } Start-Sleep -Seconds (1 * $attempt) } finally { if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) { $ProgressPreference = $OriginalProgressPreference } if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) { $VerbosePreference = $OriginalVerbosePreference } } } if ($fallbackTriggered) { $remainingAttempts = $Retries - $attempt if ($remainingAttempts -lt 1) { $remainingAttempts = 1 } $httpAttempt = 0 while ($httpAttempt -lt $remainingAttempts) { $httpAttempt++ $OriginalVerbosePreference = $VerbosePreference $OriginalProgressPreference = $ProgressPreference try { $VerbosePreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $Source -OutFile $Destination -ErrorAction Stop $ProgressPreference = $OriginalProgressPreference $VerbosePreference = $OriginalVerbosePreference WriteLog "Successfully transferred $Source to $Destination via HTTP fallback." return } catch { $lastError = $_ WriteLog "HTTP fallback attempt $httpAttempt of $remainingAttempts failed to download $Source. Error: $($lastError.Exception.Message)." Start-Sleep -Seconds (1 * $httpAttempt) } 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 } function Set-Progress { [CmdletBinding()] param( [Parameter(Mandatory)] [int]$Percentage, [Parameter(Mandatory)] [string]$Message ) WriteLog "[PROGRESS] $Percentage | $Message" } function ConvertTo-SafeName { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Name ) # Replace invalid Windows filename characters (<>:"/\|?* and control chars) with a dash $sanitized = $Name -replace '[<>:\"/\\|?*\x00-\x1F]', '-' # Collapse multiple consecutive dashes $sanitized = $sanitized -replace '-{2,}', '-' # Trim leading/trailing spaces, periods, and dashes $sanitized = $sanitized.Trim(' ', '.', '-') if ([string]::IsNullOrWhiteSpace($sanitized)) { $sanitized = 'Unnamed' } return $sanitized } Export-ModuleMember -Function *