feat: Add cleanup functionality and improve build cancellation process in UI

- Introduced flags to track if a build is in progress and if cleanup is running.
- Enhanced the button click handler to allow users to cancel an ongoing build and initiate a cleanup process.
- Implemented a mechanism to stop background jobs and terminate associated processes during cancellation.
- Added logic to manage log file reading during cleanup and ensure proper UI updates.
- Updated the state management to reflect the current operation status accurately.
This commit is contained in:
rbalsleyMSFT
2025-08-08 18:22:40 -07:00
parent 17dc80f11b
commit 7c3de6d77f
2 changed files with 669 additions and 29 deletions
+428 -17
View File
@@ -421,7 +421,9 @@ param(
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$ExportConfigFile, [string]$ExportConfigFile,
[string]$orchestrationPath, [string]$orchestrationPath,
[bool]$UpdateADK = $true [bool]$UpdateADK = $true,
[bool]$CleanupCurrentRunDownloads = $false,
[switch]$Cleanup
) )
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
$version = '2507.2' $version = '2507.2'
@@ -1664,16 +1666,16 @@ function Get-ADKURL {
$ADKUrl = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri $ADKUrl = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
if ($null -eq $ADKUrl) { if ($null -eq $ADKUrl) {
WriteLog "Could not determine final ADK download URL after redirection." WriteLog "Could not determine final ADK download URL after redirection."
return $null return $null
} }
WriteLog "Resolved ADK download URL to: $ADKUrl" WriteLog "Resolved ADK download URL to: $ADKUrl"
return $ADKUrl return $ADKUrl
} }
catch { catch {
WriteLog "An error occurred while resolving the ADK FWLink: $($_.Exception.Message)" WriteLog "An error occurred while resolving the ADK FWLink: $($_.Exception.Message)"
throw throw
} }
} }
catch { catch {
@@ -1951,7 +1953,9 @@ function Get-WindowsESD {
WriteLog "Downloading $($file.filePath) to $esdFIlePath" WriteLog "Downloading $($file.filePath) to $esdFIlePath"
$OriginalVerbosePreference = $VerbosePreference $OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue'
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $esdFilePath
Invoke-WebRequest -Uri $file.FilePath -OutFile $esdFilePath -Headers $Headers -UserAgent $UserAgent Invoke-WebRequest -Uri $file.FilePath -OutFile $esdFilePath -Headers $Headers -UserAgent $UserAgent
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $esdFilePath
$VerbosePreference = $OriginalVerbosePreference $VerbosePreference = $OriginalVerbosePreference
WriteLog "Download succeeded" WriteLog "Download succeeded"
#Set back to show progress #Set back to show progress
@@ -2021,8 +2025,10 @@ function Get-Office {
$xmlContent = [xml](Get-Content $OfficeDownloadXML) $xmlContent = [xml](Get-Content $OfficeDownloadXML)
$xmlContent.Configuration.Add.SourcePath = $OfficePath $xmlContent.Configuration.Add.SourcePath = $OfficePath
$xmlContent.Save($OfficeDownloadXML) $xmlContent.Save($OfficeDownloadXML)
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $OfficePath
WriteLog "Downloading M365 Apps/Office to $OfficePath" WriteLog "Downloading M365 Apps/Office to $OfficePath"
Invoke-Process $OfficePath\setup.exe "/download $OfficeDownloadXML" | Out-Null Invoke-Process $OfficePath\setup.exe "/download $OfficeDownloadXML" | Out-Null
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $OfficePath
WriteLog "Cleaning up ODT default config files" WriteLog "Cleaning up ODT default config files"
#Clean up default configuration files #Clean up default configuration files
@@ -3410,6 +3416,26 @@ Function New-DeploymentUSB {
function Get-FFUEnvironment { function Get-FFUEnvironment {
WriteLog 'Dirty.txt file detected. Last run did not complete succesfully. Will clean environment' WriteLog 'Dirty.txt file detected. Last run did not complete succesfully. Will clean environment'
try {
Remove-InProgressItems -FFUDevelopmentPath $FFUDevelopmentPath
}
catch {
WriteLog "Remove-InProgressItems failed: $($_.Exception.Message)"
}
if ($CleanupCurrentRunDownloads) {
try {
Cleanup-CurrentRunDownloads -FFUDevelopmentPath $FFUDevelopmentPath
}
catch {
WriteLog "Cleanup-CurrentRunDownloads failed: $($_.Exception.Message)"
}
try {
Restore-RunJsonBackups -FFUDevelopmentPath $FFUDevelopmentPath
}
catch {
WriteLog "Restore-RunJsonBackups failed: $($_.Exception.Message)"
}
}
# Check for running VMs that start with '_FFU-' and are in the 'Off' state # Check for running VMs that start with '_FFU-' and are in the 'Off' state
$vms = Get-VM $vms = Get-VM
@@ -3452,13 +3478,22 @@ function Get-FFUEnvironment {
WriteLog 'Removal complete' WriteLog 'Removal complete'
# Check for content in the VM folder and delete any folders that start with _FFU- # Check for content in the VM folder and delete any folders that start with _FFU-
$folders = Get-ChildItem -Path $VMLocation -Directory if ([string]::IsNullOrWhiteSpace($VMLocation)) {
foreach ($folder in $folders) { $VMLocation = Join-Path $FFUDevelopmentPath 'VM'
if ($folder.Name -like '_FFU-*') { WriteLog "VMLocation not set; defaulting to $VMLocation"
WriteLog "Removing folder $($folder.FullName)" }
Remove-Item -Path $folder.FullName -Recurse -Force if (Test-Path -Path $VMLocation) {
$folders = Get-ChildItem -Path $VMLocation -Directory
foreach ($folder in $folders) {
if ($folder.Name -like '_FFU-*') {
WriteLog "Removing folder $($folder.FullName)"
Remove-Item -Path $folder.FullName -Recurse -Force
}
} }
} }
else {
WriteLog "VMLocation path $VMLocation not found; skipping VM folder cleanup"
}
# Remove orphaned mounted images # Remove orphaned mounted images
$mountedImages = Get-WindowsImage -Mounted $mountedImages = Get-WindowsImage -Mounted
@@ -3522,6 +3557,13 @@ function Get-FFUEnvironment {
Remove-Item -Path $AppsISO -Force -ErrorAction SilentlyContinue Remove-Item -Path $AppsISO -Force -ErrorAction SilentlyContinue
WriteLog 'Removal complete' WriteLog 'Removal complete'
} }
# Remove per-run session folder if present (Cancel/-Cleanup scenario)
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
if (Test-Path -Path $sessionDir) {
WriteLog 'Removing .session folder'
Remove-Item -Path $sessionDir -Recurse -Force -ErrorAction SilentlyContinue
WriteLog 'Removal complete'
}
WriteLog 'Removing dirty.txt file' WriteLog 'Removing dirty.txt file'
Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force
WriteLog "Cleanup complete" WriteLog "Cleanup complete"
@@ -3683,20 +3725,384 @@ function Get-PEArchitecture {
} }
} }
function New-RunSession {
param(
[string]$FFUDevelopmentPath,
[string]$DriversFolder,
[string]$OrchestrationPath
)
try {
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
$backupDir = Join-Path $sessionDir 'backups'
$inprogDir = Join-Path $sessionDir 'inprogress'
if (-not (Test-Path $sessionDir)) { New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null }
if (-not (Test-Path $backupDir)) { New-Item -ItemType Directory -Path $backupDir -Force | Out-Null }
if (-not (Test-Path $inprogDir)) { New-Item -ItemType Directory -Path $inprogDir -Force | Out-Null }
$manifest = [ordered]@{
RunStartUtc = (Get-Date).ToUniversalTime().ToString('o')
JsonBackups = @()
OfficeXmlBackups = @()
}
if ($DriversFolder) {
$driverMapPath = Join-Path $DriversFolder 'DriverMapping.json'
if (Test-Path $driverMapPath) {
$backup = Join-Path $backupDir 'DriverMapping.json'
Copy-Item -Path $driverMapPath -Destination $backup -Force
$manifest.JsonBackups += @{ Path = $driverMapPath; Backup = $backup }
WriteLog "Backed up DriverMapping.json to $backup"
}
}
if ($OrchestrationPath) {
$wgPath = Join-Path $OrchestrationPath 'WinGetWin32Apps.json'
if (Test-Path $wgPath) {
$backup2 = Join-Path $backupDir 'WinGetWin32Apps.json'
Copy-Item -Path $wgPath -Destination $backup2 -Force
$manifest.JsonBackups += @{ Path = $wgPath; Backup = $backup2 }
WriteLog "Backed up WinGetWin32Apps.json to $backup2"
}
}
# Backup Office XMLs (DeployFFU.xml, DownloadFFU.xml) if present so we can restore them after cleanup
if ($OfficePath) {
foreach ($n in @('DeployFFU.xml', 'DownloadFFU.xml')) {
$src = Join-Path $OfficePath $n
if (Test-Path $src) {
$dst = Join-Path $backupDir $n
try {
Copy-Item -Path $src -Destination $dst -Force
$manifest.OfficeXmlBackups += @{ Path = $src; Backup = $dst }
WriteLog "Backed up $n to $dst"
}
catch { WriteLog "Failed backing up $($n): $($_.Exception.Message)" }
}
}
}
$manifestPath = Join-Path $sessionDir 'currentRun.json'
$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
WriteLog "Run session initialized at $sessionDir"
}
catch {
WriteLog "New-RunSession failed: $($_.Exception.Message)"
}
}
function Get-CurrentRunManifest {
param([string]$FFUDevelopmentPath)
$manifestPath = Join-Path $FFUDevelopmentPath '.session\currentRun.json'
if (Test-Path $manifestPath) { return (Get-Content $manifestPath -Raw | ConvertFrom-Json) }
return $null
}
function Save-RunManifest {
param([string]$FFUDevelopmentPath, [object]$Manifest)
if ($null -eq $Manifest) { return }
$manifestPath = Join-Path $FFUDevelopmentPath '.session\currentRun.json'
$Manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
}
function Mark-DownloadInProgress {
param([string]$FFUDevelopmentPath, [string]$TargetPath)
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath) -or [string]::IsNullOrWhiteSpace($TargetPath)) { return }
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
if (-not (Test-Path $sessionInprog)) { New-Item -ItemType Directory -Path $sessionInprog -Force | Out-Null }
$marker = Join-Path $sessionInprog ("{0}.marker" -f ([guid]::NewGuid()))
$payload = @{ TargetPath = $TargetPath; CreatedUtc = (Get-Date).ToUniversalTime().ToString('o') }
$payload | ConvertTo-Json -Depth 3 | Set-Content -Path $marker -Encoding UTF8
WriteLog "Marked in-progress: $TargetPath"
}
function Clear-DownloadInProgress {
param([string]$FFUDevelopmentPath, [string]$TargetPath)
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
if (-not (Test-Path $sessionInprog)) { return }
Get-ChildItem -Path $sessionInprog -Filter *.marker -ErrorAction SilentlyContinue | ForEach-Object {
try {
$data = Get-Content $_.FullName -Raw | ConvertFrom-Json
if ($data.TargetPath -eq $TargetPath) { Remove-Item -Path $_.FullName -Force }
}
catch {}
}
WriteLog "Cleared in-progress: $TargetPath"
}
function Remove-InProgressItems {
param([string]$FFUDevelopmentPath)
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
if (-not (Test-Path $sessionInprog)) { return }
function Remove-PathWithRetry {
param(
[string]$path,
[bool]$isDirectory
)
for ($i = 0; $i -lt 3; $i++) {
try {
if ($isDirectory) {
Remove-Item -Path $path -Recurse -Force -ErrorAction Stop
}
else {
# clear readonly if set
try { (Get-Item -LiteralPath $path -ErrorAction SilentlyContinue).Attributes = 'Normal' } catch {}
Remove-Item -Path $path -Force -ErrorAction Stop
}
return $true
}
catch {
Start-Sleep -Milliseconds 350
}
}
return -not (Test-Path -LiteralPath $path)
}
Get-ChildItem -Path $sessionInprog -Filter *.marker -ErrorAction SilentlyContinue | ForEach-Object {
try {
$data = Get-Content $_.FullName -Raw | ConvertFrom-Json
$target = $data.TargetPath
if (Test-Path $target) {
# Special-case Office: preserve DeployFFU.xml and DownloadFFU.xml; remove everything else with retries.
$targetFull = [System.IO.Path]::GetFullPath($target).TrimEnd('\')
$officeFull = $null
if ($OfficePath) { $officeFull = [System.IO.Path]::GetFullPath($OfficePath).TrimEnd('\') }
if ($officeFull -and ($targetFull -ieq $officeFull) -and (Test-Path $OfficePath -PathType Container)) {
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
WriteLog "Cleaning in-progress Office folder: preserving $($preserve -join ', ') and removing other content."
Get-ChildItem -Path $OfficePath -Force | ForEach-Object {
if ($preserve -notcontains $_.Name) {
$itemPath = $_.FullName
$isDir = $_.PSIsContainer
WriteLog "Removing Office item: $itemPath"
$removed = $false
try { $removed = Remove-PathWithRetry -path $itemPath -isDirectory:$isDir } catch {}
if (-not $removed) {
# If setup.exe (or ODT stub) is locked, try to stop the exact owning process by path and retry.
try {
$basename = [System.IO.Path]::GetFileName($itemPath)
if (-not $isDir -and $basename -in @('setup.exe', 'odtsetup.exe')) {
Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $itemPath } | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
$removed = Remove-PathWithRetry -path $itemPath -isDirectory:$false
}
}
catch {
WriteLog "Process stop attempt for $itemPath failed: $($_.Exception.Message)"
}
}
if (-not $removed) {
WriteLog "Failed removing Office item $itemPath after retries."
}
}
}
}
else {
WriteLog "Removing in-progress target: $target"
$isDir = Test-Path $target -PathType Container
[void](Remove-PathWithRetry -path $target -isDirectory:$isDir)
}
}
Remove-Item -Path $_.FullName -Force
}
catch {
WriteLog "Failed Remove-InProgressItems marker '$($_.FullName)': $($_.Exception.Message)"
}
}
}
function Cleanup-CurrentRunDownloads {
param([string]$FFUDevelopmentPath)
$manifest = Get-CurrentRunManifest -FFUDevelopmentPath $FFUDevelopmentPath
if ($null -eq $manifest) { WriteLog "No current run manifest; skipping current-run cleanup."; return }
$runStart = [datetime]::Parse($manifest.RunStartUtc)
# 1) Generic current-run scrub across known roots (includes Orchestration now)
$roots = @()
if ($AppsPath) { $roots += (Join-Path $AppsPath 'Win32'); $roots += (Join-Path $AppsPath 'MSStore') }
if ($DefenderPath) { $roots += $DefenderPath }
if ($MSRTPath) { $roots += $MSRTPath }
if ($OneDrivePath) { $roots += $OneDrivePath }
if ($EdgePath) { $roots += $EdgePath }
if ($KBPath) { $roots += $KBPath }
if ($orchestrationPath) { $roots += $orchestrationPath }
foreach ($root in $roots | Where-Object { $_ -and (Test-Path $_) }) {
WriteLog "Scanning for current-run items in $root"
# Remove folders created/modified this run
Get-ChildItem -Path $root -Directory -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -ge $runStart } | Sort-Object FullName -Descending | ForEach-Object {
try {
WriteLog "Removing current-run folder: $($_.FullName)"
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
catch { WriteLog "Failed removing folder $($_.FullName): $($_.Exception.Message)" }
}
# Remove files created/modified this run
Get-ChildItem -Path $root -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -ge $runStart -and $_.Name -notin @('DeployFFU.xml', 'DownloadFFU.xml') } | ForEach-Object {
try {
WriteLog "Removing current-run file: $($_.FullName)"
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
}
catch { WriteLog "Failed removing file $($_.FullName): $($_.Exception.Message)" }
}
}
# 2) Office folder policy: keep XML configs, remove everything else
if ($OfficePath -and (Test-Path $OfficePath)) {
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
WriteLog "Cleaning Office folder: preserving $($preserve -join ', ') and removing other content."
Get-ChildItem -Path $OfficePath -Force | ForEach-Object {
if ($preserve -notcontains $_.Name) {
try {
WriteLog "Removing Office item: $($_.FullName)"
if ($_.PSIsContainer) {
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
else {
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
}
}
catch { WriteLog "Failed removing Office item $($_.FullName): $($_.Exception.Message)" }
}
}
}
# 3) Remove generated update artifacts under Orchestration (Update-*.ps1) created this run
if ($orchestrationPath -and (Test-Path $orchestrationPath)) {
try {
Get-ChildItem -Path $orchestrationPath -Filter 'Update-*.ps1' -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTimeUtc -ge $runStart } | ForEach-Object {
WriteLog "Removing current-run artifact: $($_.FullName)"
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
}
}
catch { WriteLog "Failed removing Update-*.ps1 artifacts: $($_.Exception.Message)" }
# Also remove Install-Office.ps1 if created this run
$installOffice = Join-Path $orchestrationPath 'Install-Office.ps1'
if (Test-Path $installOffice) {
$fi = Get-Item $installOffice
if ($fi.LastWriteTimeUtc -ge $runStart) {
WriteLog "Removing current-run artifact: $installOffice"
Remove-Item -Path $installOffice -Force -ErrorAction SilentlyContinue
}
}
}
# 4) If Defender/OneDrive/Edge/MSRT folders exist, remove them entirely (they're session downloads)
foreach ($p in @($DefenderPath, $OneDrivePath, $EdgePath, $MSRTPath)) {
if ($p -and (Test-Path $p)) {
try {
WriteLog "Removing current-run folder (entire): $p"
Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
}
catch { WriteLog "Failed removing folder $($p): $($_.Exception.Message)" }
}
}
# 5) Remove any ESDs downloaded this run
Get-ChildItem -Path $PSScriptRoot -Filter *.esd -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -ge $runStart } | ForEach-Object {
try {
WriteLog "Removing current-run ESD: $($_.FullName)"
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
}
catch { WriteLog "Failed removing ESD $($_.FullName): $($_.Exception.Message)" }
}
# 6) Remove empty top-level subfolders under Apps (cosmetic)
if ($AppsPath -and (Test-Path $AppsPath)) {
Get-ChildItem -Path $AppsPath -Directory -ErrorAction SilentlyContinue | ForEach-Object {
try {
$any = Get-ChildItem -Path $_.FullName -Force -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -eq $any) {
WriteLog "Removing empty folder: $($_.FullName)"
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
catch { WriteLog "Failed removing empty folder $($_.FullName): $($_.Exception.Message)" }
}
}
}
function Restore-RunJsonBackups {
param([string]$FFUDevelopmentPath)
$manifest = Get-CurrentRunManifest -FFUDevelopmentPath $FFUDevelopmentPath
if ($null -eq $manifest) { return }
$runStart = [datetime]::Parse($manifest.RunStartUtc)
foreach ($entry in $manifest.JsonBackups) {
$path = $entry.Path
$backup = $entry.Backup
try {
if (Test-Path $backup) {
WriteLog "Restoring JSON from backup: $path"
Copy-Item -Path $backup -Destination $path -Force
}
}
catch { WriteLog "Failed restoring backup for $($path): $($_.Exception.Message)" }
}
$candidateJsons = @()
if ($DriversFolder) { $candidateJsons += (Join-Path $DriversFolder 'DriverMapping.json') }
if ($orchestrationPath) { $candidateJsons += (Join-Path $orchestrationPath 'WinGetWin32Apps.json') }
foreach ($jp in $candidateJsons) {
if (Test-Path $jp) {
$hasBackup = $manifest.JsonBackups | Where-Object { $_.Path -eq $jp }
if ($null -eq $hasBackup) {
$fi = Get-Item $jp
if ($fi.LastWriteTimeUtc -ge $runStart) {
WriteLog "Removing current-run JSON: $jp"
Remove-Item -Path $jp -Force -ErrorAction SilentlyContinue
}
}
}
}
}
# Restore Office XML backups if present; ensure Office folder exists and only XMLs remain
if ($manifest.OfficeXmlBackups -and $OfficePath) {
if (-not (Test-Path $OfficePath)) {
try { New-Item -ItemType Directory -Path $OfficePath -Force | Out-Null } catch {}
}
foreach ($ox in $manifest.OfficeXmlBackups) {
try {
WriteLog "Restoring Office XML from backup: $($ox.Path)"
Copy-Item -Path $ox.Backup -Destination $ox.Path -Force
}
catch { WriteLog "Failed restoring Office XML $($ox.Path): $($_.Exception.Message)" }
}
# Ensure only DeployFFU.xml and DownloadFFU.xml remain
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
Get-ChildItem -Path $OfficePath -Force -ErrorAction SilentlyContinue | ForEach-Object {
if ($preserve -notcontains $_.Name) {
try {
if ($_.PSIsContainer) { Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
else { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue }
}
catch { WriteLog "Failed removing extra Office item $($_.FullName): $($_.Exception.Message)" }
}
}
}
###END FUNCTIONS ###END FUNCTIONS
#Remove old log file if found if (-not $Cleanup) {
if (Test-Path -Path $Logfile) { #Remove old log file if found
Remove-item -Path $LogFile -Force if (Test-Path -Path $Logfile) {
Remove-item -Path $LogFile -Force
}
$startTime = Get-Date
Write-Host "FFU build process started at" $startTime
Write-Host "This process can take 20 minutes or more. Please do not close this window or any additional windows that pop up"
Write-Host "To track progress, please open the log file $Logfile or use the -Verbose parameter next time"
} }
$startTime = Get-Date
Write-Host "FFU build process started at" $startTime if ($Cleanup) {
Write-Host "This process can take 20 minutes or more. Please do not close this window or any additional windows that pop up" WriteLog 'User cancelled, starting cleanup process'
Write-Host "To track progress, please open the log file $Logfile or use the -Verbose parameter next time" WriteLog 'Cleanup requested via -Cleanup. Running Get-FFUEnvironment...'
Get-FFUEnvironment
return
}
WriteLog 'Begin Logging' WriteLog 'Begin Logging'
New-RunSession -FFUDevelopmentPath $FFUDevelopmentPath -DriversFolder $DriversFolder -OrchestrationPath $orchestrationPath
Set-Progress -Percentage 1 -Message "FFU build process started..." Set-Progress -Percentage 1 -Message "FFU build process started..."
####### Generate Config File ####### ####### Generate Config File #######
@@ -5281,6 +5687,11 @@ else {
#Clean up dirty.txt file #Clean up dirty.txt file
Remove-Item -Path .\dirty.txt -Force | out-null Remove-Item -Path .\dirty.txt -Force | out-null
# Remove per-run session folder if present
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
if (Test-Path -Path $sessionDir) {
Remove-Item -Path $sessionDir -Recurse -Force -ErrorAction SilentlyContinue
}
if ($VerbosePreference -ne 'Continue') { if ($VerbosePreference -ne 'Continue') {
Write-Host 'Script complete' Write-Host 'Script complete'
} }
+241 -12
View File
@@ -43,14 +43,17 @@ $script:uiState = [PSCustomObject]@{
vmSwitchMap = @{}; vmSwitchMap = @{};
logData = $null; logData = $null;
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null pollTimer = $null;
lastConfigFilePath = $null
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
prevInstallAppsStateBeforeUpdates = $null; prevInstallAppsStateBeforeUpdates = $null;
installAppsCheckedByOffice = $false; installAppsCheckedByOffice = $false;
lastSortProperty = $null; lastSortProperty = $null;
lastSortAscending = $true lastSortAscending = $true;
isBuilding = $false;
isCleanupRunning = $false
}; };
Defaults = @{}; Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log" LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
@@ -132,7 +135,225 @@ $script:uiState.Controls.btnRun.Add_Click({
# Get a local reference to the button for convenience in this handler # Get a local reference to the button for convenience in this handler
$btnRun = $script:uiState.Controls.btnRun $btnRun = $script:uiState.Controls.btnRun
try { try {
# Disable button to prevent multiple clicks # If a build is running and cleanup is not already running, treat this click as Cancel
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
WriteLog "Cancel requested by user. Stopping background build job."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
$script:uiState.Data.pollTimer.Stop()
$script:uiState.Data.pollTimer = $null
}
# Close the log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Stop and remove the running build job
$jobToStop = $script:uiState.Data.currentBuildJob
$script:uiState.Data.currentBuildJob = $null
if ($null -ne $jobToStop) {
try {
# Attempt graceful stop first
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null
}
catch {
WriteLog "Stop-Job threw: $($_.Exception.Message)"
}
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
try {
$jobProcId = $null
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) {
$jobProcId = $jobToStop.ChildJobs[0].ProcessId
}
if ($jobProcId) {
# Recursively terminate the job process and any children
function Stop-ProcessTree {
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
foreach ($child in $children) {
Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
Stop-ProcessTree -parentPid $jobProcId
}
}
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
}
# Safety net: kill any active DISM capture still running
try {
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
foreach ($p in $dismCaptures) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
catch {
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
}
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = Join-Path (Split-Path (Split-Path $lastConfigPath -Parent) -Parent) 'Apps\Office'
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
catch {
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
}
try {
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
WriteLog "Background build job stopped and removed."
}
catch {
WriteLog "Error removing background build job: $($_.Exception.Message)"
}
}
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
WriteLog "No stored config file path found. Cleanup cannot proceed."
$script:uiState.Controls.txtStatus.Text = "Build canceled. No config found for cleanup."
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
$btnRun.Content = "Build FFU"
$btnRun.IsEnabled = $true
return
}
$ffuDevPath = Split-Path (Split-Path $lastConfigPath -Parent) -Parent
$mainLogPath = Join-Path $ffuDevPath "FFUDevelopment.log"
WriteLog "Starting cleanup without deleting FFUDevelopment.log (will append new entries)."
$script:uiState.Controls.txtStatus.Text = "Cancel in progress... Cleaning environment..."
WriteLog "Starting cleanup job (BuildFFUVM.ps1 -Cleanup)."
# Prepare parameters for cleanup
# Inform user: in-progress items will be removed; ask whether to also remove other items downloaded during this run
$removeCurrentRunToo = $false
$promptText = "Cancel requested.`n`nWe'll remove the download currently in progress to avoid partial/corrupt content.`n`nDo you also want to remove other items downloaded during this run? Previously downloaded items will be kept."
$result = [System.Windows.MessageBox]::Show($promptText, "Cancel cleanup options", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq [System.Windows.MessageBoxResult]::Yes) { $removeCurrentRunToo = $true }
$cleanupParams = @{
ConfigFile = $lastConfigPath
Cleanup = $true
# Avoid wiping all user content on cancel
RemoveApps = $false
RemoveUpdates = $false
CleanupDrivers = $false
# Scoped removal to current run only (optional per user choice)
CleanupCurrentRunDownloads = $removeCurrentRunToo
}
$cleanupScriptBlock = {
param($buildParams, $PSScriptRoot)
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
}
# Start cleanup job
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
# Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60
$watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
Start-Sleep -Milliseconds 250
}
$watch.Stop()
# Open log stream for cleanup (tail to end to avoid re-reading the whole file)
if (Test-Path $mainLogPath) {
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
[void]$fileStream.Seek(0, [System.IO.SeekOrigin]::End)
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
}
else {
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
}
# Create a timer to poll the cleanup job
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob
# Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) {
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
if ($script:uiState.Flags.autoScrollLog) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
}
}
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
return
}
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
if ($null -ne $script:uiState.Data.logStreamReader) {
$lastLine = $null
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
$lastLine = $line
}
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
$script:uiState.Controls.txtStatus.Text = "Build canceled. Environment cleaned."
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
$script:uiState.Controls.pbOverallProgress.Value = 0
# Receive and remove cleanup job
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false
$script:uiState.Flags.isBuilding = $false
$btn = $script:uiState.Controls.btnRun
$btn.Content = "Build FFU"
$btn.IsEnabled = $true
}
})
$script:uiState.Data.pollTimer.Start()
return
}
# Not currently building: start a new build
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
# Switch to Monitor Tab # Switch to Monitor Tab
@@ -153,6 +374,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$config = Get-UIConfig -State $script:uiState $config = Get-UIConfig -State $script:uiState
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json" $configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8 $config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
$script:uiState.Data.lastConfigFilePath = $configFilePath
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) { if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
@@ -283,25 +505,21 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Determine final status based on job result and whether cleanup was running (should be false here)
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') { if ($currentJob.State -eq 'Failed') {
$reason = $null $reason = $null
# Use Receive-Job with -ErrorVariable to reliably capture the error stream from the job,
# as suggested by the research on handling job errors.
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) { if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
# The terminating error is typically the last one in the stream.
$reason = ($jobErrors | Select-Object -Last 1).ToString() $reason = ($jobErrors | Select-Object -Last 1).ToString()
} }
# If Receive-Job didn't surface an error, fall back to the JobStateInfo.Reason property.
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) { if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
$reason = $currentJob.JobStateInfo.Reason.Message $reason = $currentJob.JobStateInfo.Reason.Message
} }
# Final fallback if no specific reason can be found.
if ([string]::IsNullOrWhiteSpace($reason)) { if ([string]::IsNullOrWhiteSpace($reason)) {
$reason = "An unknown error occurred. The job failed without a specific reason." $reason = "An unknown error occurred. The job failed without a specific reason."
} }
@@ -318,19 +536,27 @@ $script:uiState.Controls.btnRun.Add_Click({
# Update UI elements # Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText $script:uiState.Controls.txtStatus.Text = $finalStatusText
$script:uiState.Controls.btnRun.IsEnabled = $true
# Clean up the job object # Receive & remove job and clear state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force Remove-Job -Job $currentJob -Force
# Clear the job from the state
$script:uiState.Data.currentBuildJob = $null $script:uiState.Data.currentBuildJob = $null
# Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
$script:uiState.Controls.btnRun.Content = "Build FFU"
$script:uiState.Controls.btnRun.IsEnabled = $true
} }
}) })
# Start the timer # Start the timer
$script:uiState.Data.pollTimer.Start() $script:uiState.Data.pollTimer.Start()
# Mark building and toggle button to Cancel
$script:uiState.Flags.isBuilding = $true
$btnRun.Content = "Cancel"
$btnRun.IsEnabled = $true
} }
catch { catch {
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails) # This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
@@ -350,6 +576,9 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
if ($null -ne $script:uiState.Controls.btnRun) { if ($null -ne $script:uiState.Controls.btnRun) {
$script:uiState.Controls.btnRun.IsEnabled = $true $script:uiState.Controls.btnRun.IsEnabled = $true
$script:uiState.Controls.btnRun.Content = "Build FFU"
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
} }
} }
}) })