Private/Notification/Show-NativeToast.ps1
|
function Show-NativeToast { <# .SYNOPSIS Shows a native Windows toast notification using Windows.UI.Notifications .DESCRIPTION Creates and displays a Windows toast notification with proper Action Center integration. Supports scenarios (Default, Reminder, Urgent), action buttons, and custom icons. Falls back to BurntToast or WPF if native APIs unavailable. .PARAMETER Title The notification title .PARAMETER Message The notification body message .PARAMETER AppName Optional application name to display .PARAMETER Scenario Toast scenario: Default, Reminder, Alarm, IncomingCall, Urgent .PARAMETER Actions Array of action hashtables with Label and Action keys .PARAMETER DeferralsRemaining Number of deferrals remaining (shown on defer button) .PARAMETER CanDefer Whether deferral is allowed .PARAMETER IconPath Path to notification icon image .PARAMETER HeroImagePath Path to hero image (displayed at top of toast) .PARAMETER ExpirationTime When the notification should expire from Action Center .PARAMETER Tag Unique tag for the notification (for updates/replacement) .PARAMETER Group Group identifier for notification grouping .EXAMPLE Show-NativeToast -Title "Update Available" -Message "Chrome needs updating" -Scenario Reminder .EXAMPLE Show-NativeToast -Title "Critical Update" -Message "Security patch required" -Scenario Urgent -CanDefer $false #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Title, [Parameter()] [string]$Message, [Parameter()] [string]$AppName, [Parameter()] [ValidateSet('Default', 'Reminder', 'Alarm', 'IncomingCall', 'Urgent')] [string]$Scenario = 'Default', [Parameter()] [hashtable[]]$Actions, [Parameter()] [int]$DeferralsRemaining = 5, [Parameter()] [bool]$CanDefer = $true, [Parameter()] [string]$IconPath, [Parameter()] [string]$HeroImagePath, [Parameter()] [datetime]$ExpirationTime, [Parameter()] [string]$Tag, [Parameter()] [string]$Group = 'PsPatchMyPC' ) $config = Get-PatchMyPCConfig # Try native Windows toast notification first if (Test-NativeToastSupport) { try { $result = Send-NativeWindowsToast @PSBoundParameters if ($result) { Write-PatchLog "Displayed native Windows toast: $Title" -Type Info return $result } } catch { Write-PatchLog "Native toast failed, trying fallback: $_" -Type Warning } } # Fallback to BurntToast if available try { $bt = Get-Module -Name BurntToast -ListAvailable -ErrorAction SilentlyContinue if ($bt) { Import-Module BurntToast -Force -ErrorAction Stop $btParams = @{ Text = @($Title, $Message) } if ($IconPath -and (Test-Path $IconPath)) { $btParams.AppLogo = $IconPath } elseif ($config.Notifications.CompanyLogoPath -and (Test-Path $config.Notifications.CompanyLogoPath)) { $btParams.AppLogo = $config.Notifications.CompanyLogoPath } # Add buttons if actions enabled if ($config.Notifications.Enterprise.EnableToastActions) { $buttons = @() $updateLabel = $config.Notifications.Enterprise.ToastActionUpdateLabel $deferLabel = $config.Notifications.Enterprise.ToastActionDeferLabel $buttons += New-BTButton -Content $updateLabel -Arguments "action=update" -ActivationType Protocol if ($CanDefer) { $deferText = if ($DeferralsRemaining -gt 0) { "$deferLabel ($DeferralsRemaining)" } else { $deferLabel } $buttons += New-BTButton -Content $deferText -Arguments "action=defer" -ActivationType Protocol } $btParams.Button = $buttons } New-BurntToastNotification @btParams Write-PatchLog "Displayed BurntToast notification: $Title" -Type Info return @{ Success = $true; Method = 'BurntToast' } } } catch { Write-PatchLog "BurntToast failed: $_" -Type Warning } # Final fallback: WPF toast with actions try { Add-Type -AssemblyName PresentationFramework, System.Windows.Forms -ErrorAction SilentlyContinue $xamlParams = @{ Title = $Title Message = $Message AccentColor = $config.Notifications.AccentColor } if ($AppName) { $xamlParams.AppName = $AppName } if ($CanDefer) { $xamlParams.CanDefer = $true $xamlParams.DeferralsRemaining = $DeferralsRemaining } $xaml = Get-ToastNotificationWithActionsXaml @xamlParams return Show-WPFToastWithActions -Xaml $xaml -Timeout 15 } catch { Write-PatchLog "All toast methods failed: $_" -Type Error return @{ Success = $false; Error = $_.Exception.Message } } } function Test-NativeToastSupport { <# .SYNOPSIS Tests if native Windows toast notifications are supported #> [CmdletBinding()] param() try { # Check Windows version (10+) $osVersion = [System.Environment]::OSVersion.Version if ($osVersion.Major -lt 10) { return $false } # Check if WinRT is available $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] return $true } catch { return $false } } function Send-NativeWindowsToast { <# .SYNOPSIS Sends a toast notification using Windows.UI.Notifications WinRT API .DESCRIPTION Creates XML-based toast notification with full Action Center support #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Title, [Parameter()] [string]$Message, [Parameter()] [string]$AppName, [Parameter()] [string]$Scenario = 'Default', [Parameter()] [hashtable[]]$Actions, [Parameter()] [int]$DeferralsRemaining = 5, [Parameter()] [bool]$CanDefer = $true, [Parameter()] [string]$IconPath, [Parameter()] [string]$HeroImagePath, [Parameter()] [datetime]$ExpirationTime, [Parameter()] [string]$Tag, [Parameter()] [string]$Group = 'PsPatchMyPC' ) $config = Get-PatchMyPCConfig # Load WinRT assemblies try { $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] } catch { throw "WinRT assemblies not available: $_" } # Build toast scenario attribute $scenarioAttr = switch ($Scenario) { 'Reminder' { ' scenario="reminder"' } 'Alarm' { ' scenario="alarm"' } 'IncomingCall' { ' scenario="incomingCall"' } 'Urgent' { ' scenario="urgent"' } default { '' } } # Build image elements $iconElement = "" if ($IconPath -and (Test-Path $IconPath)) { $iconElement = "<image placement='appLogoOverride' src='file:///$($IconPath -replace '\\','/')'/>" } elseif ($config.Notifications.CompanyLogoPath -and (Test-Path $config.Notifications.CompanyLogoPath)) { $logoPath = $config.Notifications.CompanyLogoPath -replace '\\', '/' $iconElement = "<image placement='appLogoOverride' src='file:///$logoPath'/>" } $heroElement = "" if ($HeroImagePath -and (Test-Path $HeroImagePath)) { $heroPath = $HeroImagePath -replace '\\', '/' $heroElement = "<image placement='hero' src='file:///$heroPath'/>" } # Build action buttons $actionsXml = "" if ($config.Notifications.Enterprise.EnableToastActions) { $protocol = $config.Notifications.Enterprise.ProtocolScheme $updateLabel = $config.Notifications.Enterprise.ToastActionUpdateLabel $deferLabel = $config.Notifications.Enterprise.ToastActionDeferLabel $buttonsXml = "<action content='$updateLabel' arguments='$($protocol):action=update' activationType='protocol'/>" if ($CanDefer -and $DeferralsRemaining -gt 0) { $deferText = "$deferLabel ($DeferralsRemaining)" $buttonsXml += "<action content='$deferText' arguments='$($protocol):action=defer' activationType='protocol'/>" } $actionsXml = "<actions>$buttonsXml</actions>" } # Build complete toast XML $appNameLine = if ($AppName) { "<text hint-style='captionSubtle'>$AppName</text>" } else { "" } $toastXml = @" <toast$scenarioAttr> <visual> <binding template='ToastGeneric'> <text hint-style='title'>$Title</text> $appNameLine <text>$Message</text> $iconElement $heroElement </binding> </visual> $actionsXml <audio silent='false'/> </toast> "@ # Create and show notification try { $xml = New-Object Windows.Data.Xml.Dom.XmlDocument $xml.LoadXml($toastXml) # Use PowerShell as the AUMID (works without requiring custom app registration) $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' $toast = [Windows.UI.Notifications.ToastNotification]::new($xml) if ($Tag) { $toast.Tag = $Tag } if ($Group) { $toast.Group = $Group } if ($ExpirationTime) { $toast.ExpirationTime = $ExpirationTime } $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId) $notifier.Show($toast) return @{ Success = $true Method = 'NativeWinRT' Tag = $Tag Group = $Group } } catch { throw "Failed to display native toast: $_" } } function Show-WPFToastWithActions { <# .SYNOPSIS Shows a WPF toast with action buttons and returns user choice #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Xaml, [Parameter()] [int]$Timeout = 15 ) # WPF requires STA thread $currentThread = [System.Threading.Thread]::CurrentThread if ($currentThread.GetApartmentState() -ne 'STA') { $runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'STA' $runspace.ThreadOptions = 'ReuseThread' $runspace.Open() $ps = [powershell]::Create() $ps.Runspace = $runspace $null = $ps.AddScript({ param($XamlContent, $TimeoutSecs) Add-Type -AssemblyName PresentationFramework, System.Windows.Forms -ErrorAction SilentlyContinue [xml]$xamlXml = $XamlContent $reader = New-Object System.Xml.XmlNodeReader $xamlXml $window = [Windows.Markup.XamlReader]::Load($reader) # Position bottom-right $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea $window.Left = $workingArea.Right - 440 $window.Top = $workingArea.Bottom - 180 $script:result = 'Dismissed' $script:remainingSeconds = $TimeoutSecs $closeBtn = $window.FindName("CloseButton") $deferBtn = $window.FindName("DeferButton") $updateBtn = $window.FindName("UpdateButton") $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(1) $timer.Add_Tick({ $script:remainingSeconds-- if ($script:remainingSeconds -le 0) { $timer.Stop() $script:result = 'Timeout' $window.Close() } }) if ($closeBtn) { $closeBtn.Add_Click({ $timer.Stop() $script:result = 'Dismissed' $window.Close() }) } if ($deferBtn -and $deferBtn.IsEnabled) { $deferBtn.Add_Click({ $timer.Stop() $script:result = 'Defer' $window.Close() }) } if ($updateBtn) { $updateBtn.Add_Click({ $timer.Stop() $script:result = 'Update' $window.Close() }) } $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return @{ Success = $true; Method = 'WPFToast'; Result = $script:result } }) $null = $ps.AddArgument($Xaml) $null = $ps.AddArgument($Timeout) try { $result = $ps.Invoke() return $result[0] } finally { $ps.Dispose() $runspace.Close() $runspace.Dispose() } } # Already in STA Add-Type -AssemblyName PresentationFramework, System.Windows.Forms -ErrorAction SilentlyContinue [xml]$xamlXml = $Xaml $reader = New-Object System.Xml.XmlNodeReader $xamlXml $window = [Windows.Markup.XamlReader]::Load($reader) $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea $window.Left = $workingArea.Right - 440 $window.Top = $workingArea.Bottom - 180 $script:result = 'Dismissed' $script:remainingSeconds = $Timeout $closeBtn = $window.FindName("CloseButton") $deferBtn = $window.FindName("DeferButton") $updateBtn = $window.FindName("UpdateButton") $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(1) $timer.Add_Tick({ $script:remainingSeconds-- if ($script:remainingSeconds -le 0) { $timer.Stop() $script:result = 'Timeout' $window.Close() } }) if ($closeBtn) { $closeBtn.Add_Click({ $timer.Stop() $script:result = 'Dismissed' $window.Close() }) } if ($deferBtn -and $deferBtn.IsEnabled) { $deferBtn.Add_Click({ $timer.Stop() $script:result = 'Defer' $window.Close() }) } if ($updateBtn) { $updateBtn.Add_Click({ $timer.Stop() $script:result = 'Update' $window.Close() }) } $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return @{ Success = $true; Method = 'WPFToast'; Result = $script:result } } function Remove-ToastNotification { <# .SYNOPSIS Removes a toast notification from Action Center .PARAMETER Tag The tag of the notification to remove .PARAMETER Group The group of the notification #> [CmdletBinding()] param( [Parameter()] [string]$Tag, [Parameter()] [string]$Group = 'PsPatchMyPC' ) if (-not (Test-NativeToastSupport)) { return } try { $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' $history = [Windows.UI.Notifications.ToastNotificationManager]::History if ($Tag -and $Group) { $history.Remove($Tag, $Group, $appId) } elseif ($Group) { $history.RemoveGroup($Group, $appId) } else { $history.Clear($appId) } } catch { Write-PatchLog "Failed to remove toast notification: $_" -Type Warning } } |