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)