PDQ Sticky notification - Systray and MSG box notificaitons for install complete for users to close
# File: Notify-Sticky.ps1
<#
.SYNOPSIS
Displays a notification using a tray balloon tip and/or a styled modal form.
.DESCRIPTION
Designed for PDQ Deploy using "Run As Logged On User". Can display tray and/or modal messages.
Supports blocking interaction with OK/Cancel/Timeout return codes.
.PARAMETER msg
Required. Message to display.
.PARAMETER title
Optional. Title of the modal or balloon (default: "Message").
.PARAMETER minutes
Optional. Tray icon visibility time in minutes (default: -1 = indefinite).
.PARAMETER timeout
Optional. Auto-close modal after X seconds. Returns 1 on timeout.
.PARAMETER NoSystray
Optional. Suppress tray balloon.
.PARAMETER NoForm
Optional. Suppress modal form.
.PARAMETER NoImage
Optional. Hide image from modal.
.PARAMETER Cancel
Optional. Show Cancel button. Returns -1 on cancel/[X].
.PARAMETER image
Optional. Override image path.
.PARAMETER AggressiveTopMost
Optional. Reassert TopMost/Activate on a timer to keep dialog above other windows.
.PARAMETER help
Optional. Show help message. Also available as -? and -h. Use -help/-h for the enhanced parameter table; -? shows PowerShell's built-in help.
.EXAMPLES
.\Notify-Sticky.ps1 -msg "Complete" -timeout 10
.\Notify-Sticky.ps1 -msg "Notice" -NoForm
.\Notify-Sticky.ps1 -? # show detailed usage
#>
# WHY: Enable advanced function features (common parameters, parameter sets) and a dedicated Help set
[CmdletBinding(DefaultParameterSetName = 'Run')]
param (
# WHY: Force message content; split into Run set so Help set can bypass Mandatory prompt
[Parameter(ParameterSetName = 'Run', Mandatory = $true)]
[ValidateNotNullOrEmpty()] # WHY: Prevent empty strings for a better UX
[string]$msg,
# WHY: Optional UI title with a sensible default for both balloon and modal
[Parameter(ParameterSetName = 'Run')]
[string]$title = "Message",
# WHY: Controls how long the tray icon stays visible in tray-only mode; -1 means indefinitely
[Parameter(ParameterSetName = 'Run')]
[int]$minutes = -1,
# WHY: Auto-close the modal after N seconds; drives exit code 1 on timeout
[Parameter(ParameterSetName = 'Run')]
[int]$timeout = -1,
# WHY: Suppress the tray balloon when you only want the modal
[Parameter(ParameterSetName = 'Run')]
[switch]$NoSystray,
# WHY: Suppress the modal when you only want the tray balloon
[Parameter(ParameterSetName = 'Run')]
[switch]$NoForm,
# WHY: Hide the image area in the modal when branding isn't desired or path is missing
[Parameter(ParameterSetName = 'Run')]
[switch]$NoImage,
# WHY: Show a Cancel button to capture an explicit negative/close response (-1)
[Parameter(ParameterSetName = 'Run')]
[switch]$Cancel,
# WHY: Allow overriding the default image path per invocation
[Parameter(ParameterSetName = 'Run')]
[string]$image,
# WHY: Optionally enforce an aggressive always-on-top behavior to fight focus stealers
[Parameter(ParameterSetName = 'Run')]
[switch]$AggressiveTopMost,
# WHY: Provide rich help without requiring -msg; aliases match user expectations (-?/-h)
[Parameter(ParameterSetName = 'Help')]
[Alias('?', 'h')]
[switch]$Help
)
# WHY: Centralize default branding path; keeps deployment-specific value in one place
$DEFAULT_IMAGE_PATH = "C:\MyImage.png"
function Show-Help {
<# WHY: Provide a richer, accurate, auto-generated parameter listing and per-parameter effects. #>
$scriptPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.MyCommand.Path }
$scriptName = Split-Path -Leaf $scriptPath
# WHY: Pull comment-based help so descriptions stay near code
$cbh = $null
try { $cbh = Get-Help -Full $scriptPath -ErrorAction Stop } catch { }
# WHY: Reflect on parameters (names, types, mandatory, aliases) to build a precise table
$cmd = $null
try { $cmd = Get-Command -ErrorAction Stop -Name $scriptPath } catch { }
$ourNames = @('msg','title','minutes','timeout','NoSystray','NoForm','NoImage','Cancel','image','AggressiveTopMost','help')
# WHY: Map parameter descriptions by name for easy lookup when building rows
$descMap = @{}
if ($cbh -and $cbh.Parameters -and $cbh.Parameters.Parameter) {
foreach ($p in $cbh.Parameters.Parameter) {
$text = $p.Description | ForEach-Object { $_.Text } | Where-Object { $_ } | Out-String
$descMap[$p.Name.ToLower()] = ($text -replace '
?
?', ' ').Trim()
}
}
# WHY: Capture current default values to display realistic defaults (including switches)
$defaults = @{
msg = $msg
title = $title
minutes = $minutes
timeout = $timeout
NoSystray = [bool]$NoSystray
NoForm = [bool]$NoForm
NoImage = [bool]$NoImage
Cancel = [bool]$Cancel
image = if ($null -eq $image -or $image -eq '') { $null } else { $image }
AggressiveTopMost= [bool]$AggressiveTopMost
help = [bool]$Help
}
function _fmt([object]$v) {
# WHY: Normalize display of defaults: show (none) for null, boolean for switches
if ($null -eq $v) { return '(none)' }
if ($v -is [System.Management.Automation.SwitchParameter]) { return [bool]$v }
return "$v"
}
# WHY: Friendly ordering matching how users think about options
$ordering = @('msg','title','minutes','timeout','NoSystray','NoForm','NoImage','Cancel','image','AggressiveTopMost','help')
$rows = @()
if ($cmd) { # WHY: Only attempt reflection table if metadata is available
foreach ($p in $cmd.Parameters.Values) {
if ($ourNames -notcontains $p.Name) { continue }
$paramName = $p.Name
$aliases = if ($p.Aliases) { ($p.Aliases -join ', ') } else { '' }
$mandatory = ($p.Attributes |
Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] -and $_.Mandatory } |
Measure-Object).Count -gt 0 # WHY: Compute Mandatory true/false across parameter sets
$ptype = if ($p.ParameterType) { $p.ParameterType.Name } else { '' }
$def = if ($defaults.ContainsKey($paramName)) { _fmt $defaults[$paramName] } else { '(n/a)' }
$desc = $descMap[$paramName.ToLower()]
$rows += [pscustomobject]@{
Name = $paramName
Aliases = $aliases
Mandatory = $mandatory
Type = $ptype
Default = $def
Description = if ([string]::IsNullOrWhiteSpace($desc)) { '' } else { $desc }
}
}
}
# WHY: Keep output predictable by sorting with the custom order
$rows = $rows | Sort-Object { $ordering.IndexOf($_.Name) }
$usage = @"
USAGE
$scriptName -msg <text> [options]
"@
$notes = @"
NOTES
Exit Codes:
0 OK clicked
-1 Cancel or window closed
1 Timeout or help shown
Parameter effects:
- msg : Required. Text shown in tray balloon and modal. Missing => help + exit 1.
- title : Caption for modal and title for tray tooltip. Default: 'Message'.
- minutes : Tray-only lifetime. -1 keeps tray icon indefinitely; otherwise sleeps minutes*60.
- timeout : If > 0, modal auto-closes after N seconds and returns exit code 1.
- NoSystray : Suppresses tray balloon. With -NoForm too => error + help (nothing to show).
- NoForm : Suppresses modal. Enables tray-only flow that uses -minutes for lifetime.
- NoImage : Hides image area in modal; layout shifts left; -image is ignored.
- Cancel : Adds a Cancel button; Cancel or [X] => exit -1.
- image : Path to an image to display in modal when it exists; falls back to default path.
- AggressiveTopMost : Reasserts TopMost/Activate periodically to fight focus stealers.
- help/-h : Shows this enhanced help and exits 1. '-?' also shows help (host-dependent).
Tips:
- Use -help/-h to always get this enhanced table; '-?' may invoke host help.
- Use -NoSystray or -NoForm to choose one UI; never both together.
"@
$paramHeader = "PARAMETERS`n"
# WHY: Auto-size table so columns fit common consoles without manual widths
$table = if ($rows.Count -gt 0) { $rows | Format-Table -AutoSize | Out-String } else { '(parameter metadata unavailable)' }
$examples = @"
EXAMPLES
$scriptName -msg "Complete" -timeout 10
$scriptName -msg "Notice" -NoForm
$scriptName -msg "Heads up" -title "Deploy" -minutes 1 -NoForm -NoImage
$scriptName -msg "Hello" -image "C:\\Images\\logo.png"
$scriptName -help # show this enhanced help
"@
($usage + $notes + "`n" + $paramHeader + $table + "`n" + $examples) | Write-Output
# WHY: Emit identity for observability even when showing help (exit code 1)
Write-Outcome -Code 1 -Reason 'Help'
exit 1
}
# WHY: Identity helpers for consistent, structured result logging
function Get-IdentityRecord {
try {
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
return [pscustomobject]@{
Computer = $env:COMPUTERNAME
User = $id.Name
SID = $id.User.Value
Domain = $env:USERDOMAIN
Username = $env:USERNAME
SessionId = (Get-Process -Id $PID).SessionId
}
} catch {
return [pscustomobject]@{
Computer = $env:COMPUTERNAME
User = ($env:USERDOMAIN + '\\' + $env:USERNAME)
SID = $null
Domain = $env:USERDOMAIN
Username = $env:USERNAME
SessionId = $null
}
}
}
function Write-Outcome {
param(
[int]$Code,
[string]$Reason
)
# WHY: Single, parseable line captured by deployment tools
$ts = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
$id = Get-IdentityRecord
$line = "[{0}] [RESULT] Code={1} Reason={2} Computer={3} User={4} SID={5} Session={6}" -f `
$ts,$Code,$Reason,$id.Computer,$id.User,$id.SID,$id.SessionId
Write-Output $line
}
# WHY: Early-exit when help is requested or arguments are missing; avoids Mandatory prompts
# WHY: $PSCmdlet may be $null in scripts; detect help via bound parameter instead
if ($PSBoundParameters.ContainsKey('Help') -or -not $PSBoundParameters.Count -or -not $msg) { Show-Help }
# WHY: Guardrail to prevent no-op invocations that would confuse users
if ($NoForm -and $NoSystray) { Write-Error "Nothing to show: both -NoForm and -NoSystray set."; Show-Help }
# WHY: Timestamped debug breadcrumbs simplify remote troubleshooting
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Parameters: msg='$msg' title='$title' minutes=$minutes timeout=$timeout NoSystray=$NoSystray NoForm=$NoForm NoImage=$NoImage Cancel=$Cancel image='$image' AggressiveTopMost=$AggressiveTopMost help=$Help"
Write-Host "[$ts] [DEBUG] Proceeding to load Windows Forms..."
# WHY: Explicitly load WinForms & Drawing assemblies for GUI elements in PowerShell 5+
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
function Show-CustomForm {
# WHY: Encapsulate modal UI so it is reusable and testable in isolation
param (
# WHY: Window caption for accessibility and clarity
[string]$title,
# WHY: Main message body text from caller
[string]$msg,
# WHY: Path to branding image to improve recognition
[string]$imgPath,
# WHY: Skip image rendering when disabled or path invalid
[switch]$NoImage,
# WHY: Include Cancel path to capture user intent distinctly from [X]
[switch]$Cancel,
# WHY: Auto-dismiss timer to keep deployments non-blocking
[int]$timeout,
# WHY: Optionally reassert TopMost/Activate to keep the dialog above other windows
[switch]$AggressiveTopMost
)
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Launching Show-CustomForm with title='$title', timeout=$timeout, AggressiveTopMost=$AggressiveTopMost"
# WHY: Basic fixed-size, always-on-top modal keeps UX predictable during deployments
$form = New-Object Windows.Forms.Form
$form.Text = if (![string]::IsNullOrWhiteSpace($title)) { $title } else { "Message" }
$form.Width = 600
$form.Height = 400
$form.StartPosition = "CenterScreen"
$form.TopMost = $true
$form.BackColor = [System.Drawing.Color]::WhiteSmoke
$form.FormBorderStyle = 'FixedDialog'
$form.ShowInTaskbar = $true
if (-not $NoImage) { # WHY: Only add image container when desired; reduces layout complexity otherwise
$iconBox = New-Object Windows.Forms.PictureBox
$iconBox.SizeMode = 'Zoom'
$iconBox.Width = 48
$iconBox.Height = 48
$iconBox.Left = 10
$form.Controls.Add($iconBox)
}
# WHY: Use label with wrapping constraints so long messages display nicely
$label = New-Object Windows.Forms.Label
$label.Text = $msg
$label.Font = New-Object Drawing.Font("Segoe UI", 12)
$label.AutoSize = $true
$label.MaximumSize = New-Object Drawing.Size(500, 0)
$label.Left = if (-not $NoImage) { 70 } else { 20 }
$form.Controls.Add($label)
# WHY: Defer layout tweaks until form is shown so actual client size is known
$form.Add_Shown({
$label.Top = [Math]::Max(10, ($form.ClientSize.Height - $label.Height) / 2 - 20)
if (-not $NoImage -and $iconBox -ne $null) {
$iconBox.Top = [Math]::Max(10, ($form.ClientSize.Height - $iconBox.Height) / 2 - 20)
}
$buttonTop = $form.ClientSize.Height - 50
$okBtn.Top = $buttonTop
if ($Cancel) { $cancelBtn.Top = $buttonTop }
# WHY: Bring to foreground on initial show for visibility
try { $form.Activate() | Out-Null } catch { }
})
# WHY: Optional enforcer that reasserts TopMost/Activate to fight focus stealers
if ($AggressiveTopMost) {
$enforcer = New-Object Windows.Forms.Timer
$enforcer.Interval = 1500
$enforcer.Add_Tick({
if (-not $form.Focused) {
# WHY: Toggling TopMost forces z-order update without native calls
$form.TopMost = $false
$form.TopMost = $true
try { $form.Activate() | Out-Null } catch { }
}
})
$enforcer.Start()
$form.Add_FormClosed({ if ($enforcer) { $enforcer.Stop(); $enforcer.Dispose() } })
}
# WHY: OK is the default action so keyboard Enter confirms quickly
$okBtn = New-Object Windows.Forms.Button
$okBtn.Text = "OK"
$okBtn.DialogResult = [System.Windows.Forms.DialogResult]::OK
$okBtn.Width = 90
$okBtn.Height = 30
$okBtn.Left = if ($Cancel) { 140 } else { ($form.ClientSize.Width - $okBtn.Width) / 2 }
$form.AcceptButton = $okBtn
$form.Controls.Add($okBtn)
if ($Cancel) { # WHY: Provide an explicit Cancel affordance separate from window close
$cancelBtn = New-Object Windows.Forms.Button
$cancelBtn.Text = "Cancel"
$cancelBtn.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$cancelBtn.Width = 90
$cancelBtn.Height = 30
$cancelBtn.Left = 270
$form.CancelButton = $cancelBtn
$form.Controls.Add($cancelBtn)
}
if ($timeout -gt 0) { # WHY: Enforce non-blocking behavior when a timeout is specified
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Setting timeout timer for $timeout seconds."
$timer = New-Object Windows.Forms.Timer
$timer.Interval = $timeout * 1000
$timer.Add_Tick({
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Timer expired, closing form."
$form.Tag = 'TimedOut' # WHY: Tag used post-ShowDialog to infer timeout exit path
$form.Close()
})
$timer.Start()
}
if (-not $NoImage -and $iconBox -ne $null -and (Test-Path $imgPath)) { # WHY: Avoid file exceptions; image is optional
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Loading image from: $imgPath"
try {
$iconImage = [System.Drawing.Image]::FromFile($imgPath)
$iconBox.Image = $iconImage
} catch { } # WHY: Non-fatal; branding is optional
}
$result = $form.ShowDialog() # WHY: Block until user acts or timeout fires
$timedOut = ($form.Tag -eq 'TimedOut')
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Form result: $result; TimedOut=$timedOut"
return @{ Result = $result; TimedOut = $timedOut } # WHY: Structured return simplifies caller logic
}
if (-not $NoSystray) { # WHY: Show a quick, non-blocking toast in the notification area
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Showing system tray balloon."
$tray = New-Object System.Windows.Forms.NotifyIcon
$tray.Icon = [System.Drawing.SystemIcons]::Information
$tray.Text = $title
$tray.Visible = $true
$tray.ShowBalloonTip(5000, $title, $msg, [System.Windows.Forms.ToolTipIcon]::Info)
}
if (-not $NoForm) { # WHY: Display modal when not suppressed; returns deterministic codes
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Calling Show-CustomForm..."
$imgPath = if ($image) { $image } else { $DEFAULT_IMAGE_PATH } # WHY: Per-call override with sensible default
$r = Show-CustomForm -title $title -msg $msg -imgPath $imgPath -NoImage:$NoImage -Cancel:$Cancel -timeout $timeout -AggressiveTopMost:$AggressiveTopMost
if ($r.TimedOut) { # WHY: Distinguish timeout from user actions
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Timeout triggered."
Write-Outcome -Code 1 -Reason 'Timeout'
exit 1
} elseif ($r.Result -eq [System.Windows.Forms.DialogResult]::OK) { # WHY: Positive acknowledgement path
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] OK clicked."
Write-Outcome -Code 0 -Reason 'OK'
exit 0
} else { # WHY: Cancel or window close maps to -1 to signal negative/ignored action
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Cancel or Close button clicked."
Write-Outcome -Code -1 -Reason 'CancelOrClose'
exit -1
}
}
if ($NoForm -and -not $NoSystray) { # WHY: Tray-only mode needs a lifetime so the icon stays visible
$ts = Get-Date -Format "yyyy-MM-ddTHHmmss"
Write-Host "[$ts] [DEBUG] Running in tray-only mode."
if ($minutes -eq -1) { # WHY: Keep tray icon visible indefinitely when requested
while ($true) { Start-Sleep -Seconds 1 } # WHY: Lightweight wait loop; keeps process alive for tray icon
} else { # WHY: Finite lifetime based on minutes
Start-Sleep -Seconds ($minutes * 60)
}
$tray.Visible = $false # WHY: Hide to avoid ghost icons after exit
$tray.Dispose() # WHY: Free native resources promptly
Write-Outcome -Code 0 -Reason 'TrayOnlyCompleted'
exit 0 # WHY: Tray-only completion considered success
}
Comments
Post a Comment