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. When specified, Cancel or [X] returns -1; otherwise [X] returns 0. .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 when -Cancel is specified) [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 or window closed when -Cancel is not specified -1 Cancel or window closed when -Cancel is specified 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. If specified, Cancel or [X] => exit -1; if not specified, [X] => exit 0. - 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-ddHHmmss" 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: With -Cancel, Cancel or [X] => -1; without -Cancel, [X] behaves like OK (0) $ts = Get-Date -Format "yyyy-MM-ddTHHmmss" if ($Cancel) { Write-Host "[$ts] [DEBUG] Cancel or Close button clicked with -Cancel specified." Write-Outcome -Code -1 -Reason 'CancelOrClose' exit -1 } else { Write-Host "[$ts] [DEBUG] Window closed without -Cancel; treating as OK." Write-Outcome -Code 0 -Reason 'ClosedWithoutCancel' exit 0 } } } 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

Popular posts from this blog

Powerpoint countdown and current time in slides VBA

Revit area plans adding new types and references (Gross and rentable)

Revit Dynamo View Form Python Output Form Resizable, Aggressively on top