Public/Show-PatchNotification.ps1
|
function Show-PatchNotification { <# .SYNOPSIS Shows a patch notification to the user .DESCRIPTION Displays a toast notification or dialog about pending updates .PARAMETER Type Notification type: Toast, Dialog, or Both .PARAMETER Updates Array of pending updates to notify about .PARAMETER Timeout Auto-dismiss timeout in seconds .EXAMPLE Show-PatchNotification -Type Toast -Updates $updates .EXAMPLE Show-PatchNotification -Type Dialog -Updates $updates -Timeout 300 #> [CmdletBinding()] param( [Parameter()] [ValidateSet('Toast', 'Dialog', 'Both')] [string]$Type = 'Toast', [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [int]$Timeout = 300 ) $config = Get-PatchMyPCConfig # Check if we need to run as user $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() $isSystem = $currentUser.User.Value -eq 'S-1-5-18' if ($isSystem -and (Test-UserSessionActive)) { # Run notification in user context $scriptBlock = { param($NotificationType, $UpdateCount, $TimeoutSecs) Import-Module PsPatchMyPC -Force Show-PatchNotificationInternal -Type $NotificationType -UpdateCount $UpdateCount -Timeout $TimeoutSecs } Invoke-AsCurrentUser -ScriptBlock $scriptBlock -Arguments @($Type, $Updates.Count, $Timeout) -Wait return } # Run directly Show-PatchNotificationInternal -Type $Type -Updates $Updates -Timeout $Timeout } function Show-PatchNotificationInternal { <# .SYNOPSIS Internal implementation of notification display #> [CmdletBinding()] param( [Parameter()] [string]$Type = 'Toast', [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [int]$UpdateCount, [Parameter()] [int]$Timeout = 300 ) $config = Get-PatchMyPCConfig $count = if ($Updates) { $Updates.Count } else { $UpdateCount } switch ($Type) { 'Toast' { Show-ToastNotificationInternal -UpdateCount $count -Config $config } 'Dialog' { Show-DeferralDialogFull -Updates $Updates -Config $config -Timeout $Timeout } 'Both' { Show-ToastNotificationInternal -UpdateCount $count -Config $config Start-Sleep -Seconds 2 Show-DeferralDialogFull -Updates $Updates -Config $config -Timeout $Timeout } } } function Show-ToastNotification { <# .SYNOPSIS Shows a toast notification about available updates .DESCRIPTION Displays a Windows toast notification using BurntToast or fallback WPF .PARAMETER Title Notification title .PARAMETER Message Notification message .PARAMETER Duration How long to show the toast (seconds) .EXAMPLE Show-ToastNotification -Title "Updates Available" -Message "3 updates ready to install" #> [CmdletBinding()] param( [Parameter()] [string]$Title = "Software Update Available", [Parameter()] [string]$Message = "Updates are ready to install.", [Parameter()] [int]$Duration = 10 ) Show-ToastNotificationInternal -Title $Title -Message $Message -Duration $Duration } function Show-ToastNotificationInternal { <# .SYNOPSIS Internal toast notification implementation #> [CmdletBinding()] param( [Parameter()] [int]$UpdateCount, [Parameter()] [PsPatchMyPCConfig]$Config, [Parameter()] [string]$Title, [Parameter()] [string]$Message, [Parameter()] [int]$Duration = 10 ) if (-not $Config) { $Config = Get-PatchMyPCConfig } if (-not $Title) { $Title = $Config.Notifications.ToastTitle if (-not $Title) { $Title = "Software Update Available" } } if (-not $Message) { $Message = "$UpdateCount update(s) ready to install. Click to proceed." } # Try BurntToast first try { $bt = Get-Module -Name BurntToast -ListAvailable -ErrorAction SilentlyContinue if ($bt) { Import-Module BurntToast -Force $toastParams = @{ Text = @($Title, $Message) AppLogo = $Config.Notifications.CompanyLogoPath } New-BurntToastNotification @toastParams Write-PatchLog "Displayed BurntToast notification" -Type Info return } } catch { Write-PatchLog "BurntToast failed, falling back to WPF: $_" -Type Warning } # Fallback to WPF toast try { Add-Type -AssemblyName PresentationFramework, System.Windows.Forms -ErrorAction SilentlyContinue $xaml = Get-ToastNotificationXaml -Title $Title -Message $Message -AccentColor $Config.Notifications.AccentColor $window = Show-WPFDialog -Xaml $xaml -Timeout $Duration if ($window) { # Position bottom-right $workingArea = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea $window.Left = $workingArea.Right - 420 $window.Top = $workingArea.Bottom - 140 # Auto-dismiss timer $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds($Duration) $timer.Add_Tick({ $timer.Stop() $window.Close() }) $window.Add_Loaded({ $timer.Start() }) # Close button handler $closeBtn = $window.FindName("CloseButton") if ($closeBtn) { $closeBtn.Add_Click({ $window.Close() }) } $window.ShowDialog() | Out-Null Write-PatchLog "Displayed WPF toast notification" -Type Info } } catch { Write-PatchLog "Failed to show toast notification: $_" -Type Error } } function Show-DeferralDialog { <# .SYNOPSIS Shows a deferral dialog for a pending update .DESCRIPTION Displays a modal dialog allowing user to defer or proceed with update .PARAMETER AppId The application ID to show dialog for .PARAMETER Timeout Auto-proceed timeout in seconds .EXAMPLE $result = Show-DeferralDialog -AppId 'Google.Chrome' if ($result -eq 'Install') { Install-WingetUpdate -Id 'Google.Chrome' } #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AppId, [Parameter()] [int]$Timeout = 300 ) # Get update info $update = Get-PatchStatus -AppId $AppId | Select-Object -First 1 if (-not $update) { Write-PatchLog "No update found for $AppId" -Type Warning return 'NoUpdate' } $config = Get-PatchMyPCConfig return Show-DeferralDialogFull -Updates @($update) -Config $config -Timeout $Timeout } function Show-DeferralDialogFull { <# .SYNOPSIS Shows full deferral dialog with countdown #> [CmdletBinding()] param( [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [PsPatchMyPCConfig]$Config, [Parameter()] [int]$Timeout = 300 ) if (-not $Config) { $Config = Get-PatchMyPCConfig } if (-not $Updates -or $Updates.Count -eq 0) { return 'NoUpdates' } $update = $Updates[0] $deferralState = Get-StateFromRegistry -AppId $update.AppId $deferralsRemaining = $deferralState.GetRemainingDeferrals() $canDefer = $deferralState.CanDefer() # WPF requires STA thread - run in separate runspace if needed $currentThread = [System.Threading.Thread]::CurrentThread if ($currentThread.GetApartmentState() -ne 'STA') { Write-PatchLog "Running dialog in STA runspace..." -Type Info # Create STA runspace for WPF $runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'STA' $runspace.ThreadOptions = 'ReuseThread' $runspace.Open() $ps = [powershell]::Create() $ps.Runspace = $runspace $null = $ps.AddScript({ param($AppName, $InstalledVersion, $AvailableVersion, $DeferralsRemaining, $CanDefer, $AccentColor, $Timeout) Add-Type -AssemblyName PresentationFramework $xaml = @" <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" Title="Update Required" Height="300" Width="500" WindowStyle="None" AllowsTransparency="True" Background="Transparent" Topmost="True" WindowStartupLocation="CenterScreen" ResizeMode="NoResize"> <Border CornerRadius="12" Background="#FF2D2D30" BorderBrush="$AccentColor" BorderThickness="2" Margin="10"> <Grid Margin="25"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="*"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <TextBlock Grid.Row="0" Text="Software Update Required" FontSize="20" FontWeight="Bold" Foreground="White"/> <TextBlock Grid.Row="1" Text="$AppName ($InstalledVersion -> $AvailableVersion)" FontSize="16" Foreground="$AccentColor" Margin="0,15,0,5"/> <TextBlock Grid.Row="2" TextWrapping="Wrap" Foreground="#CCCCCC" FontSize="14" Margin="0,10"> A software update is ready to install. Save your work and click Update Now. </TextBlock> <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,10"> <TextBlock Text="Auto-installing in: " Foreground="#888888" FontSize="14"/> <TextBlock Name="CountdownText" Text="5:00" Foreground="$AccentColor" FontSize="14" FontWeight="Bold"/> </StackPanel> <StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <Button Name="DeferButton" Content="Defer ($DeferralsRemaining remaining)" Width="160" Height="36" Margin="0,0,12,0" Background="#FF3F3F3F" Foreground="White" BorderThickness="0"/> <Button Name="UpdateButton" Content="Update Now" Width="120" Height="36" Background="$AccentColor" Foreground="White" BorderThickness="0"/> </StackPanel> </Grid> </Border> </Window> "@ [xml]$xamlXml = $xaml $reader = New-Object System.Xml.XmlNodeReader $xamlXml $window = [Windows.Markup.XamlReader]::Load($reader) $script:dialogResult = 'Timeout' $script:remainingSeconds = $Timeout $countdownText = $window.FindName("CountdownText") $deferButton = $window.FindName("DeferButton") $updateButton = $window.FindName("UpdateButton") if (-not $CanDefer) { $deferButton.IsEnabled = $false $deferButton.Content = "No deferrals left" } $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(1) $timer.Add_Tick({ $script:remainingSeconds-- $mins = [Math]::Floor($script:remainingSeconds / 60) $secs = $script:remainingSeconds % 60 $countdownText.Text = "{0}:{1:D2}" -f $mins, $secs if ($script:remainingSeconds -le 0) { $timer.Stop() $script:dialogResult = 'Install' $window.Close() } }) $deferButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Defer' $window.Close() }) $updateButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Install' $window.Close() }) $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return $script:dialogResult }) $null = $ps.AddArgument($update.AppName) $null = $ps.AddArgument($update.InstalledVersion) $null = $ps.AddArgument($update.AvailableVersion) $null = $ps.AddArgument($deferralsRemaining) $null = $ps.AddArgument($canDefer) $null = $ps.AddArgument($Config.Notifications.AccentColor) $null = $ps.AddArgument($Timeout) try { $result = $ps.Invoke() Write-PatchLog "Deferral dialog result: $($result[0])" -Type Info return $result[0] } finally { $ps.Dispose() $runspace.Close() $runspace.Dispose() } } # Already in STA - run directly try { Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue $message = $Config.Notifications.DialogMessage if (-not $message) { $message = "Critical updates are ready to install. Save your work - applications may close automatically." } $xaml = Get-DeferralDialogXaml ` -Title $Config.Notifications.DialogTitle ` -Message $message ` -AppName "$($update.AppName) ($($update.InstalledVersion) -> $($update.AvailableVersion))" ` -DeferralsRemaining $deferralsRemaining ` -AccentColor $Config.Notifications.AccentColor $window = Show-WPFDialog -Xaml $xaml -Timeout $Timeout if (-not $window) { return 'Error' } $script:result = 'Timeout' $script:remainingSeconds = $Timeout $countdownText = $window.FindName("CountdownText") $deferButton = $window.FindName("DeferButton") $updateButton = $window.FindName("UpdateButton") if (-not $canDefer -and $deferButton) { $deferButton.IsEnabled = $false $deferButton.Content = "No deferrals remaining" } $timer = New-Object System.Windows.Threading.DispatcherTimer $timer.Interval = [TimeSpan]::FromSeconds(1) $timer.Add_Tick({ $script:remainingSeconds-- $mins = [Math]::Floor($script:remainingSeconds / 60) $secs = $script:remainingSeconds % 60 $countdownText.Text = "{0}:{1:D2}" -f $mins, $secs if ($script:remainingSeconds -le 0) { $timer.Stop() $script:result = 'Install' $window.Close() } }) if ($deferButton) { $deferButton.Add_Click({ $timer.Stop() $script:result = 'Defer' $window.Close() }) } if ($updateButton) { $updateButton.Add_Click({ $timer.Stop() $script:result = 'Install' $window.Close() }) } $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null Write-PatchLog "Deferral dialog result: $script:result" -Type Info return $script:result } catch { Write-PatchLog "Failed to show deferral dialog: $_" -Type Error return 'Error' } } |