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

Popular posts from this blog

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

Revit CSV file manager for families and re-exporting to a CSV file