powershellwinformsasynchronouseventsrunspace

Register-ObjectEvent to async uploading a file using System.Net.WebClient stops working if i'm use System.Windows.Forms in a powershell script


I want to download files from the Internet on the Form and display the download progress in the ProgressBar. To do this I subscribe to loading events and do asynchronous loading. Everything is working. Here is the simplified code (removed all unnecessary):

Add-Type -assembly System.Windows.Forms

$isDownloaded = $False
$webMain = New-Object System.Net.WebClient

Register-ObjectEvent -InputObject $webMain -EventName 'DownloadFileCompleted' -SourceIdentifier WebMainDownloadFileCompleted -Action {    
    $Global:isDownloaded = $True
}

Register-ObjectEvent -InputObject $webMain -EventName 'DownloadProgressChanged' -SourceIdentifier WebMainDownloadProgressChanged -Action {
    $Global:Data = $event
}

function DownloadFile($Link, $Path, $Name) {

    write-host "begin"

    $Global:webMain.DownloadFileAsync($Link, $Path)
    
    While (!$isDownloaded) {
        $percent = $Global:Data.SourceArgs.ProgressPercentage
        If ($percent -ne $null) {
            write-host $percent
        }
        Wait-Event -Timeout 1
    }

    write-host "end"
}

DownloadFile 'https://www.7-zip.org/a/7z2301-x64.exe' 'D:\7Zip.exe' '7Zip'

Everything is working. Now I add it anywhere in the code

$Form1 = New-Object System.Windows.Forms.Form

or

$Button1 = New-Object System.Windows.Forms.Button

and the script doesn't work. The DownloadProgressChanged and DownloadFileCompleted events do not occur at all.

Question: Why does just creating a Form or Button interfere with the script?

Without System.Windows.Forms.Form the code works, but I will eventually need to create a Form and render the loading on it.

DownloadFileAsync is work - the file is downloaded, but the events in Register-ObjectEvent themselves do not occur when using any New-Object System.Windows.Forms.* (but work great without them).


Solution

  • As stated in comments, PowerShell is not a great language for async programming, the issue is that .ShowDialog() blocks the thread and is not allowing your events to execute normally. Solution to this is to register the events in a separated runspace, below is as minimal example of how this can be accomplished (as minimal as I could). I have added a few pointer comments to help you understand the logic, though the code is clearly not easy, as stated before, PowerShell is not a language designed for this, C# would give you a much easier time.

    Demo:

    demo

    Code:

    Add-Type -Assembly System.Windows.Forms
    
    [System.Windows.Forms.Application]::EnableVisualStyles()
    
    $form = [System.Windows.Forms.Form]@{
        Size            = '500, 150'
        FormBorderStyle = 'Fixed3d'
    }
    $btn = [System.Windows.Forms.Button]@{
        Name     = 'MyButton'
        Text     = 'Click Me!'
        Size     = '90, 30'
        Location = '370, 70'
        Anchor   = 'Bottom, Right'
    }
    $btn.Add_Click({
        # disable the button here to allow a single download at a time
        $this.Enabled = $false
        # hardcoded link here for demo
        $downloader.DownloadFileAsync('https://www.7-zip.org/a/7z2301-x64.exe', "$pwd\7Zip.exe")
    })
    $progress = [System.Windows.Forms.ProgressBar]@{
        Name     = 'MyProgressBar'
        Size     = '460, 40'
        Location = '10, 10'
    }
    $form.Controls.AddRange(($btn, $progress))
    
    # create a WebClient instance
    $downloader = [System.Net.WebClient]::new()
    # create a new runspace where the Download Events will execute
    # this new runspace will have the PSHost hooked so that things like
    # `Write-Host`, `Out-Host`, `Write-Warning`, etc. goes straight to the console
    # its easier for troubleshooting but not mandatory
    $rs = [runspacefactory]::CreateRunspace($Host)
    $rs.Open()
    # add the `$form` instance to the runspace scope
    $rs.SessionStateProxy.PSVariable.Set([psvariable]::new('form', $form))
    
    # the code that will initiate the events in the new runspace
    $ps = [powershell]::Create().AddScript({
        $registerObjectEventSplat = @{
            InputObject      = $args[0] # $args[0] = $downloader
            EventName        = 'DownloadProgressChanged'
            SourceIdentifier = 'WebMainDownloadProgressChanged'
            Action           = {
                $progress = $form.Controls.Find('MyProgressBar', $false)[0]
                # for demo, in addition to increment the progress bar,
                # show the percentage to the console
                $eventArgs.ProgressPercentage | Out-Host
                # increment the progress bar
                $progress.Value = $eventArgs.ProgressPercentage
            }
        }
        Register-ObjectEvent @registerObjectEventSplat
        $registerObjectEventSplat['EventName'] = 'DownloadFileCompleted'
        $registerObjectEventSplat['SourceIdentifier'] = 'WebMainDownloadFileCompleted'
        $registerObjectEventSplat['Action'] = {
            [System.Threading.Monitor]::Enter($form)
            # when the download is completed, enable the button
            $form.Controls.Find('MyButton', $false)[0].Enabled = $true
            # and show this to the console
            Write-Host 'Download Completed!'
            [System.Threading.Monitor]::Exit($form)
        }
        Register-ObjectEvent @registerObjectEventSplat
    }).AddArgument($downloader)
    $ps.Runspace = $rs
    $task = $ps.BeginInvoke()
    $form.ShowDialog()