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. Supports enterprise notification escalation based on deferral phase. .PARAMETER Type Notification type: Toast, Dialog, FullScreen, Both, or Auto Auto will select based on deferral phase (enterprise escalation) .PARAMETER Updates Array of pending updates to notify about .PARAMETER Timeout Auto-dismiss timeout in seconds .PARAMETER DeferralPhase Current deferral phase (used for Auto notification type) .EXAMPLE Show-PatchNotification -Type Toast -Updates $updates .EXAMPLE Show-PatchNotification -Type Dialog -Updates $updates -Timeout 300 .EXAMPLE Show-PatchNotification -Type Auto -Updates $updates -DeferralPhase Elapsed #> [CmdletBinding()] param( [Parameter()] [ValidateSet('Toast', 'Dialog', 'FullScreen', 'Both', 'Auto')] [string]$Type = 'Toast', [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [int]$Timeout = 300, [Parameter()] [DeferralPhase]$DeferralPhase = [DeferralPhase]::Initial ) $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, $Phase) Import-Module PsPatchMyPC -Force Show-PatchNotificationInternal -Type $NotificationType -UpdateCount $UpdateCount -Timeout $TimeoutSecs -DeferralPhase $Phase } Invoke-AsCurrentUser -ScriptBlock $scriptBlock -Arguments @($Type, $Updates.Count, $Timeout, $DeferralPhase.ToString()) -Wait return } # Run directly Show-PatchNotificationInternal -Type $Type -Updates $Updates -Timeout $Timeout -DeferralPhase $DeferralPhase } function Get-NotificationTypeForPhase { <# .SYNOPSIS Determines the appropriate notification type based on deferral phase .DESCRIPTION Implements enterprise notification escalation logic: - Initial: Standard toast - Approaching: Reminder toast (persistent) - Imminent: Urgent toast + Dialog - Elapsed: Full-screen interstitial #> [CmdletBinding()] param( [Parameter(Mandatory)] [DeferralPhase]$Phase, [Parameter()] [int]$DismissalCount = 0, [Parameter()] [PsPatchMyPCConfig]$Config ) if (-not $Config) { $Config = Get-PatchMyPCConfig } $enterprise = $Config.Notifications.Enterprise $escalateAfter = if ($enterprise.EscalateAfterDismissals) { [int]$enterprise.EscalateAfterDismissals } else { 3 } # Check if should escalate to full-screen based on dismissal count if ($DismissalCount -ge $escalateAfter -and $enterprise.EscalateToFullScreen) { return @{ Type = 'FullScreen' Scenario = 'Urgent' AllowDefer = ($Phase -ne [DeferralPhase]::Elapsed) } } switch ($Phase) { 'Initial' { return @{ Type = 'Toast' Scenario = $enterprise.InitialToastScenario AllowDefer = $true } } 'Approaching' { return @{ Type = 'Toast' Scenario = $enterprise.ApproachingToastScenario AllowDefer = $true } } 'Imminent' { return @{ Type = 'Both' # Toast + Dialog Scenario = $enterprise.ImminentToastScenario AllowDefer = $true } } 'Elapsed' { if ($enterprise.EscalateToFullScreen) { return @{ Type = 'FullScreen' Scenario = $enterprise.ElapsedToastScenario AllowDefer = $true # Allow 1-hour defer even in elapsed } } else { return @{ Type = 'Dialog' Scenario = 'Urgent' AllowDefer = $true # 1-hour only } } } default { return @{ Type = 'Toast' Scenario = 'Default' AllowDefer = $true } } } } function Get-ToastScenarioForPhase { <# .SYNOPSIS Gets the toast scenario string for the current deferral phase #> [CmdletBinding()] param( [Parameter(Mandatory)] [DeferralPhase]$Phase, [Parameter()] [PsPatchMyPCConfig]$Config ) if (-not $Config) { $Config = Get-PatchMyPCConfig } $enterprise = $Config.Notifications.Enterprise if (-not $enterprise) { return 'Default' } switch ($Phase) { 'Initial' { return $enterprise.InitialToastScenario } 'Approaching' { return $enterprise.ApproachingToastScenario } 'Imminent' { return $enterprise.ImminentToastScenario } 'Elapsed' { return $enterprise.ElapsedToastScenario } default { return 'Default' } } } function Show-EnterpriseNotification { <# .SYNOPSIS Shows an enterprise notification with automatic escalation .DESCRIPTION Intelligently selects notification type based on deferral phase, dismissal history, and configuration. Implements the full RUXIM-style notification escalation pattern. .PARAMETER Updates Array of pending updates .PARAMETER DeferralState Current deferral state for the primary update .PARAMETER Config PsPatchMyPC configuration .OUTPUTS Hashtable with Result (Install/Defer/Dismissed/Timeout) and Method .EXAMPLE $result = Show-EnterpriseNotification -Updates $updates -DeferralState $state if ($result.Result -eq 'Install') { Install-Updates } #> [CmdletBinding()] param( [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [DeferralState]$DeferralState, [Parameter()] [PsPatchMyPCConfig]$Config, [Parameter()] [int]$Timeout ) if (-not $Config) { $Config = Get-PatchMyPCConfig } $phase = if ($DeferralState) { $DeferralState.Phase } else { [DeferralPhase]::Initial } $canDefer = if ($DeferralState) { $DeferralState.CanDefer() } else { $true } $deferralsRemaining = if ($DeferralState) { $DeferralState.GetRemainingDeferrals() } else { 5 } # Get dismissal count from state $dismissalCount = Get-NotificationDismissalCount -AppId ($Updates[0].AppId) # Determine notification type $notifConfig = Get-NotificationTypeForPhase -Phase $phase -DismissalCount $dismissalCount -Config $Config Write-PatchLog "Enterprise notification: Phase=$phase, Type=$($notifConfig.Type), Scenario=$($notifConfig.Scenario), CanDefer=$canDefer" -Type Info # Set timeout based on phase if not specified if (-not $Timeout) { $Timeout = switch ($phase) { 'Initial' { $Config.Notifications.DialogTimeoutSeconds } 'Approaching' { $Config.Notifications.DialogTimeoutSeconds } 'Imminent' { 180 } # 3 minutes 'Elapsed' { $Config.Notifications.Enterprise.FullScreenTimeoutSeconds } default { $Config.Notifications.DialogTimeoutSeconds } } } $result = @{ Result = 'Dismissed' Method = $notifConfig.Type Phase = $phase } switch ($notifConfig.Type) { 'Toast' { $toastResult = Show-NativeToast ` -Title $Config.Notifications.ToastTitle ` -Message $Config.Notifications.ToastMessage ` -AppName $Updates[0].AppName ` -Scenario $notifConfig.Scenario ` -DeferralsRemaining $deferralsRemaining ` -CanDefer $canDefer $result.Result = if ($toastResult.Result) { $toastResult.Result } else { 'Dismissed' } $result.Method = $toastResult.Method } 'Dialog' { $dialogResult = Show-DeferralDialogFull ` -Updates $Updates ` -Config $Config ` -Timeout $Timeout $result.Result = $dialogResult } 'FullScreen' { $fsResult = Show-FullScreenPrompt ` -Updates $Updates ` -Config $Config ` -Timeout $Timeout ` -AllowDefer $notifConfig.AllowDefer $result.Result = $fsResult } 'Both' { # Show toast first, then dialog Show-NativeToast ` -Title $Config.Notifications.ToastTitle ` -Message "Updates require your attention" ` -AppName $Updates[0].AppName ` -Scenario $notifConfig.Scenario ` -DeferralsRemaining $deferralsRemaining ` -CanDefer $canDefer Start-Sleep -Seconds 2 $dialogResult = Show-DeferralDialogFull ` -Updates $Updates ` -Config $Config ` -Timeout $Timeout $result.Result = $dialogResult $result.Method = 'Toast+Dialog' } } # Update dismissal count if dismissed if ($result.Result -in @('Dismissed', 'Timeout')) { Update-NotificationDismissalCount -AppId ($Updates[0].AppId) -Increment } elseif ($result.Result -in @('Install', 'Update')) { # Reset dismissal count on action Update-NotificationDismissalCount -AppId ($Updates[0].AppId) -Reset } return $result } function Get-NotificationDismissalCount { <# .SYNOPSIS Gets the notification dismissal count for an app #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AppId ) $regPath = "HKLM:\SOFTWARE\PsPatchMyPC\NotificationState\$AppId" try { if (Test-Path $regPath) { $count = Get-ItemProperty -Path $regPath -Name 'DismissalCount' -ErrorAction SilentlyContinue if ($count) { return [int]$count.DismissalCount } } } catch { } return 0 } function Update-NotificationDismissalCount { <# .SYNOPSIS Updates the notification dismissal count for an app #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$AppId, [Parameter()] [switch]$Increment, [Parameter()] [switch]$Reset ) $regPath = "HKLM:\SOFTWARE\PsPatchMyPC\NotificationState\$AppId" try { if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null } if ($Reset) { Set-ItemProperty -Path $regPath -Name 'DismissalCount' -Value 0 -Type DWord Set-ItemProperty -Path $regPath -Name 'LastReset' -Value (Get-Date -Format 'o') -Type String } elseif ($Increment) { $current = Get-NotificationDismissalCount -AppId $AppId Set-ItemProperty -Path $regPath -Name 'DismissalCount' -Value ($current + 1) -Type DWord Set-ItemProperty -Path $regPath -Name 'LastDismissal' -Value (Get-Date -Format 'o') -Type String } } catch { Write-PatchLog "Failed to update notification dismissal count: $_" -Type Warning } } 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, [Parameter()] [string]$DeferralPhase = 'Initial' ) $config = Get-PatchMyPCConfig $count = if ($Updates) { $Updates.Count } else { $UpdateCount } # Parse deferral phase if string $phase = try { [DeferralPhase]$DeferralPhase } catch { [DeferralPhase]::Initial } # Handle Auto type - use enterprise escalation logic if ($Type -eq 'Auto') { if ($Updates -and $Updates.Count -gt 0) { $deferralState = Get-StateFromRegistry -AppId $Updates[0].AppId $result = Show-EnterpriseNotification -Updates $Updates -DeferralState $deferralState -Config $config -Timeout $Timeout return $result.Result } else { # No updates info, fall back to toast $Type = 'Toast' } } # Get scenario based on deferral phase for toasts $scenario = Get-ToastScenarioForPhase -Phase $phase -Config $config switch ($Type) { 'Toast' { $appName = if ($Updates -and $Updates[0]) { $Updates[0].AppName } else { $null } $canDefer = $true $deferralsRemaining = 5 if ($Updates -and $Updates[0]) { $deferralState = Get-StateFromRegistry -AppId $Updates[0].AppId if ($deferralState) { $canDefer = $deferralState.CanDefer() $deferralsRemaining = $deferralState.GetRemainingDeferrals() } } # Use native toast with enterprise features Show-NativeToast ` -Title $config.Notifications.ToastTitle ` -Message "$count update(s) ready to install. Click to proceed." ` -AppName $appName ` -Scenario $scenario ` -DeferralsRemaining $deferralsRemaining ` -CanDefer $canDefer } 'Dialog' { Show-DeferralDialogFull -Updates $Updates -Config $config -Timeout $Timeout } 'FullScreen' { # Determine if defer should be allowed based on phase $allowDefer = ($phase -ne [DeferralPhase]::Elapsed) -or ($phase -eq [DeferralPhase]::Elapsed) # Allow 1hr defer even in elapsed Show-FullScreenPrompt -Updates $Updates -Config $config -Timeout $Timeout -AllowDefer $allowDefer } 'Both' { $appName = if ($Updates -and $Updates[0]) { $Updates[0].AppName } else { $null } Show-NativeToast ` -Title $config.Notifications.ToastTitle ` -Message "Updates require your attention" ` -AppName $appName ` -Scenario $scenario 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' } } function Show-FullScreenPrompt { <# .SYNOPSIS Shows a full-screen interstitial prompt (RUXIM-style) .DESCRIPTION Displays a maximized, topmost window for critical updates when deferral deadline has elapsed. Replicates Microsoft's Windows 11 upgrade prompt pattern. .PARAMETER Updates Array of pending updates .PARAMETER Config PsPatchMyPC configuration object .PARAMETER Timeout Auto-proceed timeout in seconds .PARAMETER AllowDefer Allow one-hour deferral option (for elapsed phase) .OUTPUTS 'Update', 'Defer', or 'Timeout' .EXAMPLE $result = Show-FullScreenPrompt -Updates $updates -Timeout 300 #> [CmdletBinding()] param( [Parameter()] [PatchStatus[]]$Updates, [Parameter()] [PsPatchMyPCConfig]$Config, [Parameter()] [int]$Timeout = 300, [Parameter()] [bool]$AllowDefer = $false ) if (-not $Config) { $Config = Get-PatchMyPCConfig } # Check if we need to run as user try { $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() $isSystem = $currentUser.User.Value -eq 'S-1-5-18' } catch { $isSystem = $false } if ($isSystem -and (Test-UserSessionActive)) { # Run in user session try { $resultDir = Join-Path $env:PUBLIC 'PsPatchMyPC' if (-not (Test-Path $resultDir)) { New-Item -Path $resultDir -ItemType Directory -Force | Out-Null } $resultFile = Join-Path $resultDir ("fullscreen_prompt_{0}.txt" -f ([guid]::NewGuid().ToString('n'))) if (Test-Path $resultFile) { Remove-Item -Path $resultFile -Force -ErrorAction SilentlyContinue } $moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $manifest = Join-Path $moduleRoot 'PsPatchMyPC.psd1' $sb = [scriptblock]::Create(@" Import-Module '$manifest' -Force -ErrorAction SilentlyContinue `$cfg = Get-PatchMyPCConfig `$choice = Show-FullScreenPrompt -Config `$cfg -Timeout $Timeout -AllowDefer `$$AllowDefer Set-Content -Path '$resultFile' -Value `$choice -Encoding ASCII -Force "@) Invoke-AsCurrentUser -ScriptBlock $sb -Wait | Out-Null if (Test-Path $resultFile) { $choice = (Get-Content -Path $resultFile -ErrorAction SilentlyContinue | Select-Object -First 1) Remove-Item -Path $resultFile -Force -ErrorAction SilentlyContinue if ($choice -in @('Update', 'Defer', 'Timeout')) { return $choice } } } catch { Write-PatchLog "Failed to show full-screen prompt in user session: $_" -Type Warning } return 'Timeout' } # 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 $enterpriseConfig = $Config.Notifications.Enterprise $title = if ($enterpriseConfig.FullScreenTitle) { $enterpriseConfig.FullScreenTitle } else { "Action Required" } $message = if ($enterpriseConfig.FullScreenMessage) { $enterpriseConfig.FullScreenMessage } else { "Important updates must be installed now." } $heroImage = $enterpriseConfig.FullScreenHeroImage $null = $ps.AddScript({ param($Title, $Subtitle, $Message, $AccentColor, $AllowDefer, $Timeout, $HeroImage) Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue $xamlParams = @{ Title = $Title Subtitle = $Subtitle Headline = "Your device requires attention" Message = $Message AccentColor = $AccentColor ShowDeferButton = $AllowDefer DeferButtonLabel = "Remind me in 1 hour" } if ($HeroImage) { $xamlParams.HeroImagePath = $HeroImage } $xaml = Get-FullScreenInterstitialXaml @xamlParams [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") $updateButton = $window.FindName("UpdateButton") $deferButton = $window.FindName("DeferButton") $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 = 'Update' $window.Close() } }) if ($updateButton) { $updateButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Update' $window.Close() }) } if ($deferButton -and $AllowDefer) { $deferButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Defer' $window.Close() }) } # Allow Escape to minimize (not close) $window.Add_KeyDown({ param($sender, $e) if ($e.Key -eq 'Escape') { $window.WindowState = 'Minimized' } }) $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return $script:dialogResult }) $null = $ps.AddArgument($title) $null = $ps.AddArgument($Config.Notifications.CompanyName) $null = $ps.AddArgument($message) $null = $ps.AddArgument($Config.Notifications.AccentColor) $null = $ps.AddArgument($AllowDefer) $null = $ps.AddArgument($Timeout) $null = $ps.AddArgument($heroImage) try { $r = $ps.Invoke() Write-PatchLog "Full-screen prompt result: $($r[0])" -Type Info return $r[0] } finally { $ps.Dispose() $runspace.Close() $runspace.Dispose() } } # Already in STA - run directly try { Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue $enterpriseConfig = $Config.Notifications.Enterprise $title = if ($enterpriseConfig.FullScreenTitle) { $enterpriseConfig.FullScreenTitle } else { "Action Required" } $message = if ($enterpriseConfig.FullScreenMessage) { $enterpriseConfig.FullScreenMessage } else { "Important updates must be installed now." } $xamlParams = @{ Title = $title Subtitle = $Config.Notifications.CompanyName Headline = "Your device requires attention" Message = $message AccentColor = $Config.Notifications.AccentColor ShowDeferButton = $AllowDefer DeferButtonLabel = "Remind me in 1 hour" } if ($enterpriseConfig.FullScreenHeroImage) { $xamlParams.HeroImagePath = $enterpriseConfig.FullScreenHeroImage } $xaml = Get-FullScreenInterstitialXaml @xamlParams [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") $updateButton = $window.FindName("UpdateButton") $deferButton = $window.FindName("DeferButton") $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 = 'Update' $window.Close() } }) if ($updateButton) { $updateButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Update' $window.Close() }) } if ($deferButton -and $AllowDefer) { $deferButton.Add_Click({ $timer.Stop() $script:dialogResult = 'Defer' $window.Close() }) } $window.Add_KeyDown({ param($sender, $e) if ($e.Key -eq 'Escape') { $window.WindowState = 'Minimized' } }) $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null Write-PatchLog "Full-screen prompt result: $script:dialogResult" -Type Info return $script:dialogResult } catch { Write-PatchLog "Failed to show full-screen prompt: $_" -Type Error return 'Timeout' } } function Show-RebootPrompt { <# .SYNOPSIS Shows a reboot prompt (Restart now / Later) using the same WPF UX as PsPatchMyPC. .OUTPUTS 'RestartNow' or 'Later' #> [CmdletBinding()] param( [Parameter()] [PsPatchMyPCConfig]$Config, [Parameter()] [int]$Timeout = 300 ) if (-not $Config) { $Config = Get-PatchMyPCConfig } # If running as SYSTEM, show the prompt in the active user session via Invoke-AsCurrentUser try { $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() $isSystem = $currentUser.User.Value -eq 'S-1-5-18' } catch { $isSystem = $false } if ($isSystem) { try { $resultDir = Join-Path $env:PUBLIC 'PsPatchMyPC' if (-not (Test-Path $resultDir)) { New-Item -Path $resultDir -ItemType Directory -Force | Out-Null } $resultFile = Join-Path $resultDir ("reboot_prompt_{0}.txt" -f ([guid]::NewGuid().ToString('n'))) if (Test-Path $resultFile) { Remove-Item -Path $resultFile -Force -ErrorAction SilentlyContinue } $selfModulePath = $PSScriptRoot # PsPatchMyPC/Public $moduleRoot = Split-Path $selfModulePath -Parent $manifest = Join-Path $moduleRoot 'PsPatchMyPC.psd1' $sb = [scriptblock]::Create(@" Import-Module '$manifest' -Force -ErrorAction SilentlyContinue \$cfg = Get-PatchMyPCConfig \$choice = Show-RebootPrompt -Config \$cfg -Timeout $Timeout Set-Content -Path '$resultFile' -Value \$choice -Encoding ASCII -Force "@) Invoke-AsCurrentUser -ScriptBlock $sb -Wait | Out-Null if (Test-Path $resultFile) { $choice = (Get-Content -Path $resultFile -ErrorAction SilentlyContinue | Select-Object -First 1) Remove-Item -Path $resultFile -Force -ErrorAction SilentlyContinue if ($choice -in @('RestartNow', 'Later')) { return $choice } } } catch { Write-PatchLog "Failed to show reboot prompt in user session: $_" -Type Warning } return 'Later' } # WPF requires STA thread - run in separate runspace if needed $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($AccentColor, $CompanyName, $Timeout) Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue $xaml = Get-RebootPromptDialogXaml -AccentColor $AccentColor -CompanyName $CompanyName $window = Show-WPFDialog -Xaml $xaml -Timeout $Timeout if (-not $window) { return 'Later' } $script:dialogResult = 'Later' $restartButton = $window.FindName("RestartButton") $laterButton = $window.FindName("LaterButton") if ($restartButton) { $restartButton.Add_Click({ $script:dialogResult = 'RestartNow' $window.Close() }) } if ($laterButton) { $laterButton.Add_Click({ $script:dialogResult = 'Later' $window.Close() }) } # Auto-close timeout => Later $script:remainingSeconds = $Timeout $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:dialogResult = 'Later' $window.Close() } }) $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return $script:dialogResult }) $null = $ps.AddArgument($Config.Notifications.AccentColor) $null = $ps.AddArgument($Config.Notifications.CompanyName) $null = $ps.AddArgument($Timeout) try { $r = $ps.Invoke() return $r[0] } finally { $ps.Dispose() $runspace.Close() $runspace.Dispose() } } # Already in STA - run directly try { Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue $xaml = Get-RebootPromptDialogXaml -AccentColor $Config.Notifications.AccentColor -CompanyName $Config.Notifications.CompanyName $window = Show-WPFDialog -Xaml $xaml -Timeout $Timeout if (-not $window) { return 'Later' } $script:dialogResult = 'Later' $restartButton = $window.FindName("RestartButton") $laterButton = $window.FindName("LaterButton") if ($restartButton) { $restartButton.Add_Click({ $script:dialogResult = 'RestartNow' $window.Close() }) } if ($laterButton) { $laterButton.Add_Click({ $script:dialogResult = 'Later' $window.Close() }) } $script:remainingSeconds = $Timeout $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:dialogResult = 'Later' $window.Close() } }) $window.Add_Loaded({ $timer.Start() }) $window.ShowDialog() | Out-Null return $script:dialogResult } catch { Write-PatchLog "Failed to show reboot prompt: $_" -Type Warning return 'Later' } } |