2

I start building up a small winform with Copy button and a label under this button. When I click on Copy button it starts to copy files from source to destination. I would like to run this asynchroniously so I don't want form to be freezed while copy operation runs. That's why I use Job. After a successful copy I need feedback of copy and show an "OK" text with green color but it is not working.

Here is my code:

[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[System.Windows.Forms.Application]::EnableVisualStyles()

Function Copy-Action{

    $Computername = "testclient"

    $Source_Path = "C:\temp\"
    $Destination_Path = "\\$Computername\c$\temp"


    $job = Start-Job -Name "Copy" -ArgumentList $Source_Path,$Destination_Path –ScriptBlock {
           param($Source_Path,$Destination_Path) 
                    
           Copy-Item $Source_Path -Destination $Destination_Path -Recurse -Force
            
            } 
 
    Register-ObjectEvent $job StateChanged -MessageData $Status_Label -Action {
        [Console]::Beep(1000,500)
        $Status_Label.Text = "OK"
        $Status_Label.ForeColor = "#009900"
        $eventSubscriber | Unregister-Event
        $eventSubscriber.Action | Remove-Job
        } | Out-Null
}

# DRAW FORM
$form_MainForm = New-Object System.Windows.Forms.Form
$form_MainForm.Text = "Test Copy"
$form_MainForm.Size = New-Object System.Drawing.Size(200,200)
$form_MainForm.FormBorderStyle = "FixedDialog"
$form_MainForm.StartPosition = "CenterScreen"
$form_MainForm.MaximizeBox = $false
$form_MainForm.MinimizeBox = $true
$form_MainForm.ControlBox = $true

# Copy Button
$Copy_Button = New-Object System.Windows.Forms.Button
$Copy_Button.Location = "50,50"
$Copy_Button.Size = "75,30"
$Copy_Button.Text = "Copy"
$Copy_Button.Add_Click({Copy-Action})
$form_MainForm.Controls.Add($Copy_Button)

# Status Label
$Status_Label = New-Object System.Windows.Forms.Label
$Status_Label.Text = ""
$Status_Label.AutoSize = $true
$Status_Label.Location = "75,110"
$Status_Label.ForeColor = "black"
$form_MainForm.Controls.Add($Status_Label)

#show form
$form_MainForm.Add_Shown({$form_MainForm.Activate()})
[void] $form_MainForm.ShowDialog()

Copy is successful but showing an "OK" label won't. I have placed a Beep but it doesn't work too. What am I doing wrong ? Any solution to this? Thank you.

Techno
  • 37
  • 8

2 Answers2

4

Let me offer alternatives to balrundel's helpful solution - which is effective, but complex.

The core problem is that while a form is being shown modally, with .ShowDialog(), WinForms is in control of the foreground thread, not PowerShell.

That is, PowerShell code - in the form's event handlers - only executes in response to user actions, which is why your job-state-change event handler passed to Register-ObjectEvent's -Action parameter does not fire (it would eventually fire, after closing the form).

There are two fundamental solutions:

  • Stick with .ShowDialog() and perform operations in parallel, in a different PowerShell runspace (thread).

    • balrundel's solution uses the PowerShell SDK to achieve this, whose use is far from trivial, unfortunately.

    • See below for a simpler alternative based on Start-ThreadJob

  • Show the form non-modally, via the .Show() method, and enter a loop in which you can perform other operations while periodically calling [System.Windows.Forms.Application]::DoEvents() in order to keep the form responsive.

    • See this answer for an example of this technique.

    • A hybrid approach is to stick with .ShowDialog() and enter a [System.Windows.Forms.Application]::DoEvents() loop inside the form event handler.

      • This is best limited to a single event handler applying this technique, as using additional simultaneous [System.Windows.Forms.Application]::DoEvents() loops invites trouble.
      • See this answer for an example of this technique.

Simpler, Start-ThreadJob-based solution:

  • Start-ThreadJob is part of the the ThreadJob module that offers a lightweight, thread-based alternative to the child-process-based regular background jobs and is also a more convenient alternative to creating runspaces via the PowerShell SDK.

    • It comes with PowerShell (Core) 7+ and can be installed on demand in Windows PowerShell with, e.g., Install-Module ThreadJob -Scope CurrentUser.
    • In most cases, thread jobs are the better choice, both for performance and type fidelity - see the bottom section of this answer for why.
  • In addition to syntactic convenience, Start-ThreadJob, due to being thread-based (rather than using a child process, which is what Start-Job does), allows manipulating the calling thread's live objects.

    • Note that the sample code below, in the interest of brevity, performs no explicit thread synchronization, which may situationally be required.

The following simplified, self-contained sample code demonstrates the technique:

  • The sample shows a simple form with a button that starts a thread job, and updates the form from inside that thread job after the operation (simulated by a 3-second sleep) completes, as shown in the following screen shots:

    • Initial state:
      • start state
    • After pressing Start Job (the form remains responsive):
      • running state
    • After the job has ended:
      • end state
  • The .add_Click() event handler contains the meat of the solution; the source-code comments hopefully provide enough documentation.

# PSv5+
using namespace System.Windows.Forms
using namespace System.Drawing

Add-Type -AssemblyName System.Windows.Forms

# Create a sample form.
$form = [Form] @{ 
  Text = 'Form with Thread Job'
  ClientSize = [Point]::new(200, 80)
  FormBorderStyle = 'FixedToolWindow'
}

# Create the controls and add them to the form.
$form.Controls.AddRange(@(

    ($btnStartJob = [Button] @{
        Text     = "Start Job"
        Location = [Point]::new(10, 10)
      })

    [Label] @{
      Text     = "Status:"
      AutoSize = $true
      Location = [Point]::new(10, 40)
      Font     = [Font]::new('Microsoft Sans Serif', 10)
    }

    ($lblStatus = [Label] @{
        Text     = "(Not started)"
        AutoSize = $true
        Location = [Point]::new(80, 40)
        Font     = [Font]::new('Microsoft Sans Serif', 10)
      })

  ))

# The script-level helper variable that maintains a collection of
# thread-job objects created in event-handler script blocks,
# which must be cleaned up after the form closes.
$script:jobs = @()

# Add an event handler to the button that starts 
# the background job.
$btnStartJob.add_Click( {
    $this.Enabled = $false # To prevent re-entry while the job is still running.
    # Signal the status.
    $lblStatus.Text = 'Running...'
    $form.Refresh() # Update the UI.
    # Start the thread job, and add the job-info object to 
    # the *script-level* $jobs collection.
    # The sample job simply sleeps for 3 seconds to simulate a long-running operation.
    # Note:
    #  * The $using: scope is required to access objects in the caller's thread.
    #  * In this simple case you don't need to maintain a *collection* of jobs -
    #    you could simply discard the previous job, if any, and start a new one,
    #    so that only one job object is ever maintained.
    $script:jobs += Start-ThreadJob { 
      # Perform the long-running operation.
      Start-Sleep -Seconds 3 
      # Update the status label and re-enable the button.
      ($using:lblStatus).Text = 'Done'
      ($using:btnStartJob).Enabled = $true 
    }
  })

$form.ShowDialog()

# Clean up the collection of jobs.
$script:jobs | Remove-Job -Force
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    Very nice summary, I will try this solution too. Thanks for your efforts to use your time writing all of this in order to help others with your competence. Nice, appreciate it. ☺️ – Techno Nov 12 '21 at 20:26
2

Start-Job creates a separate process, and when your form is ready to receive events, it can't listen to job events. You need to create a new runspace, which is able to synchronize thread and form control.

I adapted code from this answer. You can read much better explanation there.

[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")
[System.Windows.Forms.Application]::EnableVisualStyles()

Function Copy-Action{

    $SyncHash = [hashtable]::Synchronized(@{TextBox = $Status_Label})
    $Runspace = [runspacefactory]::CreateRunspace()
    $Runspace.ThreadOptions = "UseNewThread"
    $Runspace.Open()
    $Runspace.SessionStateProxy.SetVariable("SyncHash", $SyncHash)          
    $Worker = [PowerShell]::Create().AddScript({
        $SyncHash.TextBox.Text = "Copying..."
        
        # Copy-Item
        $Computername = "testclient"

        $Source_Path = "C:\temp\"
        $Destination_Path = "\\$Computername\c$\temp"

        Copy-Item $Source_Path -Destination $Destination_Path -Recurse -Force

        
        $SyncHash.TextBox.ForeColor = "#009900"
        $SyncHash.TextBox.Text = "OK"

    })
    $Worker.Runspace = $Runspace
    $Worker.BeginInvoke()

}

# DRAW FORM
$form_MainForm = New-Object System.Windows.Forms.Form
$form_MainForm.Text = "Test Copy"
$form_MainForm.Size = New-Object System.Drawing.Size(200,200)
$form_MainForm.FormBorderStyle = "FixedDialog"
$form_MainForm.StartPosition = "CenterScreen"
$form_MainForm.MaximizeBox = $false
$form_MainForm.MinimizeBox = $true
$form_MainForm.ControlBox = $true

# Copy Button
$Copy_Button = New-Object System.Windows.Forms.Button
$Copy_Button.Location = "50,50"
$Copy_Button.Size = "75,30"
$Copy_Button.Text = "Copy"
$Copy_Button.Add_Click({Copy-Action})
$form_MainForm.Controls.Add($Copy_Button)

# Status Label
$Status_Label = New-Object System.Windows.Forms.Label
$Status_Label.Text = ""
$Status_Label.AutoSize = $true
$Status_Label.Location = "75,110"
$Status_Label.ForeColor = "black"
$form_MainForm.Controls.Add($Status_Label)

#show form
$form_MainForm.Add_Shown({$form_MainForm.Activate()})
[void] $form_MainForm.ShowDialog()
mklement0
  • 382,024
  • 64
  • 607
  • 775
balrundev
  • 336
  • 1
  • 6