Search code examples
powershellasynchronousprogress

How to run a progress bar asynchronously while performing silent installs?


The Goal:

I want to asynchronously run a progress bar (which shows elapsed time/estimated time) while waiting for silent installs. For instance,

RunWithProgressBar "cmd /c """" /Wait ""Setup.exe""" $(New-Timespan -Minutes 5)

Closest I've Come:

## Functions

function global:BottleOfBeer {
  $beer = $args[0]
  if ($beer -eq 1) {
    echo "$beer beer on the wall.";
  } elseif ($beer -eq 0) {
    echo "No beer left on the wall!";
  } else {
    echo "$beer beers left on the wall.";
  }
  sleep 1
}

function global:BeersOnTheWall {
  $NumBeers = $args[0]
  for ($i=$NumBeers; $i -ge 0; $i--) {
    BottleOfBeer $i
  }
}

function global:Install {
    cmd /c @"
        "AutoHotkey112306_Install.exe" /S /D="%cd%"
"@
}

function global:Uninstall {
    cmd /c start "" /wait "Installer.ahk" /Uninstall
}

####START Progress Bar Stuff
function global:DisplayProgress {
  $display = $args[0]
  Write-Progress    -Activity "Running..." -Status "$display"
}

function global:FormatDisplay {
  $StartTime = $args[0]
  $RunningTime = ($args[1]).Elapsed
  $EstimatedTime = $args[2]
  $RunningTimeDisplay = $([string]::Format("{0:d2}:{1:d2}:{2:d2}",
    $RunningTime.hours, 
    $RunningTime.minutes, 
    $RunningTime.seconds))
  $EstimatedEnd = $StartTime + $EstimatedTime
  return $([string]::Format("(Start: {0}) (Elapsed/Estimated: {1}/{2}) (EstimatedEnd: {3})",
    $StartTime.ToShortTimeString(), 
    $RunningTimeDisplay, 
    $EstimatedTime,
    $EstimatedEnd.ToShortTimeString()))
}

function global:TearDownProgressBar {
  $job = $args[0]
  $event = $args[1]
  $job,$event | Stop-Job -PassThru | Remove-Job #stop the job and event listener
  Write-Progress -Activity "Working..." -Completed -Status "All done."
}

function RunWithProgressBar {
  $Payload = $args[0]
  $EstimatedTime = $args[1]

  $global:StartTime = Get-Date
  $global:RunningTime = [System.Diagnostics.Stopwatch]::StartNew()
  $global:EstimatedTime = $EstimatedTime

  $progressTask = {
    while($true) {
      Register-EngineEvent -SourceIdentifier MyNewMessage -Forward
      $null = New-Event -SourceIdentifier MyNewMessage -MessageData "Pingback from job."
      Start-Sleep -Seconds 1
    }
  }

  $job = Start-Job -ScriptBlock $progressTask
  $event = Register-EngineEvent -SourceIdentifier MyNewMessage -Action {
    DisplayProgress $(FormatDisplay $global:StartTime $global:RunningTime $global:EstimatedTime)
  }

  try {
    sleep 1
    Invoke-Expression $Payload
  } finally {
    TearDownProgressBar $job $event
  }
}
####END Progress Bar Stuff

## MAIN

RunWithProgressBar "BeersOnTheWall 2"  $(New-Timespan -Seconds 3)
RunWithProgressBar "Install"  $(New-Timespan -Seconds 30)
RunWithProgressBar "Uninstall"  $(New-Timespan -Seconds 5)
RunWithProgressBar "BeersOnTheWall 2"  $(New-Timespan -Seconds 3)

The Problem

Although the above implementation runs as intended, whenever the payload argument of RunWithProgressBar is an install the event which updates the progress bar stops getting triggered.

What I'm Looking For:

How to modify my current implementation to update the progress bar every second, even while performing an install?


Solution

  • While the solution provided earned the bounty, it's not what I ended up doing. Here's the final version of RunWithProgressBar():

    param ( 
        [alias("IM")]
        [bool]$IgnoreMain = $false
    )
    
    function RunAsBAT([string]$commands) {
        # Write commands to bat file
        $tempFile = $global:scriptDir + '\TemporaryBatFile.bat'
        $commands = "@echo off `n" + $commands
        Out-File -InputObject $commands -FilePath $tempFile -Encoding ascii
        # Wait for bat file to run
        & $tempFile
        # Delete bat file
        Remove-Item -Path $tempFile
    }
    
    function DisplayProgress([string]$display) {
        Write-Progress  -Activity "Running..." -Status "$display"
    }
    
    function FormatDisplay([System.DateTime]$StartTime, [System.TimeSpan]$RunningTime, [System.TimeSpan]$EstimatedTime) {
        $RunningTimeDisplay = $([string]::Format("{0:d2}:{1:d2}:{2:d2}",
            $RunningTime.hours, 
            $RunningTime.minutes, 
            $RunningTime.seconds))
        $EstimatedEnd = $StartTime + $EstimatedTime
        return $([string]::Format("(Start: {0}) (Elapsed/Estimated: {1}/{2}) (EstimatedEnd: {3})",
            $StartTime.ToShortTimeString(), 
            $RunningTimeDisplay, 
            $EstimatedTime,
            $EstimatedEnd.ToShortTimeString()))
    }
    
    function RunWithProgressBar([scriptblock]$payload, [System.TimeSpan]$EstimatedTime) {
        $global:StartTime = Get-Date
        $global:RunningTime = [System.Diagnostics.Stopwatch]::StartNew()
        $global:EstimatedTime = $EstimatedTime
    
        try {
            $logFile = $global:scriptDir + '\TemporaryLogFile.txt'
            $StartInfo = New-Object System.Diagnostics.ProcessStartInfo -Property @{
                            FileName = 'Powershell'
                            # load this script but don't run MAIN (to expose functions/variables); 
                            # run the payload (and also log to file);
                            # if error, pause (so the window stays open to display the error)
                            Arguments = ". $global:scriptPath -IM 1; & $payload | Tee-Object -file $logFile;" + ' if ( $LastExitCode -ne 0 ) { cmd /c pause }'
                            UseShellExecute = $true
                        }
            $Process = New-Object System.Diagnostics.Process
            $Process.StartInfo = $StartInfo
            [void]$Process.Start()
    
            do
            {
                DisplayProgress $(FormatDisplay $global:StartTime ($global:RunningTime).Elapsed $global:EstimatedTime)
                Start-Sleep -Seconds 1
            }
            while (!$Process.HasExited)
    
        }
        finally {
            if (Test-Path $logFile) {
                Get-Content -Path $logFile
                Remove-Item -Path $logFile
            } else {
                Write-Host "No output was logged..."
            }
            Write-Progress  -Activity "Working..." -Completed -Status "All done."
        }
    }
    
    function TestBlockingCall {
        RunAsBAT(@"
            timeout 5
    "@)
    }
    
    ## MAIN
    
    if (-Not $IgnoreMain) {
        RunWithProgressBar { TestBlockingCall } $(New-Timespan -Seconds 7)
    }