1

I have this code to create a very simple hidden process with an icon tray an a context menu. when the function gets activated via contest menu the script starts sending the letter "q" every 5 seconds.

[System.Reflection.Assembly]::LoadWithPartialName('presentationframework')   | out-null
[System.Reflection.Assembly]::LoadWithPartialName('System.Drawing')          | out-null
[System.Reflection.Assembly]::LoadWithPartialName('WindowsFormsIntegration') | out-null

$icon = [System.Drawing.Icon]::ExtractAssociatedIcon("C:\Windows\System32\mmc.exe") 

# Part - Add the systray menu
    
$Main_Tool_Icon = New-Object System.Windows.Forms.NotifyIcon
$Main_Tool_Icon.Text = "WPF Systray tool"
$Main_Tool_Icon.Icon = $icon
$Main_Tool_Icon.Visible = $true

$Menu_Exit = New-Object System.Windows.Forms.MenuItem
$Menu_Exit.Text = "Exit"

$contextmenu = New-Object System.Windows.Forms.ContextMenu
$Main_Tool_Icon.ContextMenu = $contextmenu

#by Franco
$Menu_Key = New-Object System.Windows.Forms.MenuItem
$Menu_Key.Text = "Invia pressione tasto ogni 5 secondi"

$Main_Tool_Icon.contextMenu.MenuItems.AddRange($Menu_Exit)
$Main_Tool_Icon.contextMenu.MenuItems.AddRange($Menu_Key)

# When Exit is clicked, close everything and kill the PowerShell process
$Menu_Exit.add_Click({
    $Main_Tool_Icon.Visible = $false
    Stop-Process $pid

 })

#send Key "q" every 5 seconds
 $Menu_key.add_Click({
$a = new-object -com "wscript.shell"

    while ($true){

sleep 5 
$a.sendkeys("q")

}
 })

 
# Make PowerShell Disappear
$windowcode = '[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);'
$asyncwindow = Add-Type -MemberDefinition $windowcode -name Win32ShowWindowAsync -namespace Win32Functions -PassThru
$null = $asyncwindow::ShowWindowAsync((Get-Process -PID $pid).MainWindowHandle, 0) 

# Force garbage collection just to start slightly lower RAM usage.
[System.GC]::Collect()

# Create an application context for it to all run within.
# This helps with responsiveness, especially when clicking Exit.
$appContext = New-Object System.Windows.Forms.ApplicationContext
[void][System.Windows.Forms.Application]::Run($appContext)

the problem happens when I want to exit the script with the exit menu context button, it doesn't actually exit until I click again on the icon tray with the left mouse button. As soon as I click the icon with the left mouse button the icon disappears and it stops sending the letter "q". The problem doesn't occur when I exit the scrip wia icon context menu without first activating the sendkeys method via context menu. but if I change the code like this:

#send Key "q" every 5 seconds

$Menu_key.add_Click({

$a = new-object -com "wscript.shell"

$c = 0

while ($c -lt 5){

sleep 5

$a.sendkeys("q")

$c++

}

})

it exits, after clicking the same exit button, only after the while cycle ended, is there a way to interupt the cycle before it finishes?

I also don't understand what the following part of the script does:

$appContext = New-Object System.Windows.Forms.ApplicationContext

[void][System.Windows.Forms.Application]::Run($appContext)

and

#Force garbage collection just to start slightly lower RAM usage.

[System.GC]::Collect()

I download the model on gitHub: Build-PS1-Systray-Tool

Thanks for the help

Franco

1 Answers1

0

I also don't understand what the following part of the script does:

[System.GC]::Collect()

It forces synchronous garbage collection, i.e. the reclaiming of memory occupied by objects that are no longer being referenced by any application.
Doing so is not relevant to your script, given that there's no obvious reason to do this: there has been no build-up of no-longer-referenced objects that may cause memory pressure that therefor needs relieving.

$appContext = New-Object System.Windows.Forms.ApplicationContext
[void][System.Windows.Forms.Application]::Run($appContext)

This code is necessary in order for your notification-area (system-tray) icon to process (WinForms) GUI events, so that interacting with the associated context menu works as expected.

Specifically, it starts a blocking windows message loop via [System.Windows.Forms.Application]::Run() that runs until the application-context object stored in $appContext ([System.Windows.Forms.ApplicationContext] signals the desire to exit via a call to .ExitThread().

This is analogous to a call to .ShowDialog() in the more typical WinForms scenario where a form is to be shown modally; in that case, the blocking call ends when the form is closed.


Unless you explicit create additional threads, your script runs in a single thread, including the script blocks that server as your event delegates.

You mention ending up using Start-ThreadJob for performing your periodic task, which is actually the preferable solution, as it enables you to perform even long-running tasks in parallel, without interfering with the responsiveness of your UI.

Indeed it is the single-threaded nature of your original script that caused the problem: your sleep 5 (Start-Sleep -Seconds 5) call in the $Menu_key.add_Click() event handler blocked UI event processing, causing the symptoms you saw.

There are ways to avoid this, but creating a separate thread gives you more flexibility.
That said, in your particular case - running a quick action every N seconds - using a WinForms timer object may be the simplest solution.


The following self-contained sample code demonstrates three approaches to running an action periodically in the context of a WinForms application:

  • Use of a thread job with Start-ThreadJob to run the action in a separate thread.

  • Use of a WinForms timer object (System.Windows.Forms.Timer) to automatically invoke a script block every N seconds.

  • Use of a nested, manual event-processing loop via System.Windows.Forms.Application.DoEvents that calls in - of necessity short - intervals to process UI events while performing other operations in between.
    Note: This works well in single-form, single-notification-icon scenarios, but should be avoided otherwise.

    • A way to avoid a nested DoEvents() loop is to forgo use of an application context and [Application]::Run() altogether, and instead implement a single DoEvents() loop in the main thread - see this answer

Note:

Add-Type -AssemblyName System.Windows.Forms

Write-Verbose -Verbose 'Setting up a notification (system-tray) with a context menu, using PowerShell''s icon...'

# Create the context menu.
$contextMenuStrip = [System.Windows.Forms.ContextMenuStrip]::new()

# Create the notification (systray) icon and attach the context menu to it.
$notifyIcon = 
  [System.Windows.Forms.NotifyIcon] @{
    Text        = "WinForms notification-area utility"
    # Use PowerShell's icon
    Icon        = [System.Drawing.Icon]::ExtractAssociatedIcon((Get-Process -Id $PID).Path) 
    ContextMenuStrip = $contextMenuStrip
    Visible     = $true
  }

# Add menu items to the context menu.
$contextMenuStrip.Items.AddRange(@(
    ($menuItemExit = [System.Windows.Forms.ToolStripMenuItem] @{ Text = 'Exit' })
    ($menuItemDoPeriodically = [System.Windows.Forms.ToolStripMenuItem] @{ Text = 'Do something periodically' })
  ))

# How frequently to take action.
$periodInSeconds = 2

# Set up a WinForms timer that is invoked periodically - to be started later.
$timer = [System.Windows.Forms.Timer] @{ InterVal = $periodInSeconds * 1000 }
$timer.Add_Tick({
  # Sample action: Print a verbose message to the console.
  # See note re need for the action to run quickly below.
  Write-Verbose -vb Tick
})

# Set up the context menu-item event handlers:

$menuItemExit.add_Click({
    # Dispose of the timer and the thread job and set the script-scope variable to signal that exiting has been requested.
    $timer.Dispose()
    if ($script:threadJob) { $threadJob | Remove-Job -Force }
    $script:done = $true
    # Tell the WinForms application context to exit its message loop.
    # This will cause the [System.Windows.Forms.Application]::Run() call below to return.
    $appContext.ExitThread()
    # Dispose of and thereby implicitly remove the notification icon.
    $notifyIcon.Dispose() 
  })

$menuItemDoPeriodically.add_Click({

    Write-Verbose -Verbose "Starting periodic actions, first invocation in $periodInSeconds seconds..."

    # Best solution, using a thread job.
    # Since it runs in a *separate thread*, there's no concern about blocking the
    # main thread with long-running operations, such as extended sleeps.
    $script:threadJob = Start-ThreadJob {
      while ($true) {
        Start-Sleep $using:periodInSeconds
        # Sample action: print a message to the console.
        # Note: [Console]::WriteLine() writes directly to the display, for demo purposes.
        #       Otherwise, the output from this thread job would have to be collected on
        #       demand with Receive-Job 
        [Console]::WriteLine('Tock')
      }
    }

    # *Single-threaded solutions* that rely on whatever runs periodically
    # to run *quickly enough* so as to not block event processing, which
    # is necessary to keep the notification icon and its context menu responsive.

    # Solution with the previously set up WinForms timer.
    $timer.Start()

    # Alternative: A manual loop, which allows foreground activity while waiting
    # for the next period to elapse.
    # To keep the notification icon and its context menu responsive, 
    # [System.Windows.Forms.Application]::DoEvents() must be called in *short intervals*,
    # i.e. whatever action you perform must run *quickly enough* so as to keep the UI responsive.
    # This effectively makes *this* loop the UI event loop, which works in this simple case,
    # but is generally best avoided.
    # Keep looping until the script-level $done variable is set to $true
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    while (-not $script:done) {
      if ($sw.Elapsed.TotalSeconds -ge $periodInSeconds) { # Period has elapsed.
        $sw.Reset(); $sw.Start()
        # Sample action: Print a verbose message to the console.
        Write-Verbose -vb Tack
      }
      # Ensure that WinForms GUI events continue to be processed.
      [System.Windows.Forms.Application]::DoEvents()
      # Sleep a little to lower CPU usage, but short enough to keep the
      # context menu responsive.
      Start-Sleep -Milliseconds 100
    }
  })

# Activate this block to hide the current PowerShell window.
# For this demo, the window is kept visible to see event output.
# # Hide this PowerShell window.
# (Add-Type -PassThru -NameSpace NS$PID -Name CHideMe -MemberDefinition '
#     [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
#     public static void HideMe() { ShowWindow(System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle, 0 /* SW_HIDE */); }
# ')::HideMe()

# Initialize the script-level variable that signals whether the script should exit.
$done = $false

Write-Verbose -Verbose @'
Starting indefinite event processing for the notification-area icon (look for the PowerShell icon)...
Use its context menu:
 * to start periodic actions
 * to exit - do NOT use Ctrl-C.
'@

# Create an application context for processing WinForms GUI events.
$appContext = New-Object System.Windows.Forms.ApplicationContext

# Synchronously start a WinForms event loop for the application context.
# This call will block until the Exit context-menu item is invoked.
$null = [System.Windows.Forms.Application]::Run($appContext)

# The script will now exit.
# Note: If you run the script from an *interactive* session, the session will live on (see below)
#       In a PowerShell CLI call, the process will terminate.
Write-Verbose -Verbose 'Exiting...'

# # Activate this statement to *unconditionally* terminate the process,
# # even when running interactively.
# $host.SetShouldExit(0)
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    Thank you very much!! @mklemant0 you are a really guru of powershell! I took a couple of day to study your answer. the updated script you rewrote is very precious to me. I don't think there are many people who know powershel that well – Franco Polato Apr 05 '23 at 07:23