Thursday 6 December 2018

Creating a restart popup - Part II

Continuing from Part I, the following PowerShell code is what I developed to run the restart popup that would inform a user their PC required a restart to complete installing pending windows updates. The code has been separated into multiple functions due to its length, which allowed for each component to be developed in isolation before being brought together - which drastically helped with diagnosing issues as they occurred.

Add-ScriptEvent
This adds the capacity to output information to a log file as the script runs. As the popup was to be rolled out campus-wide it was crucial to be able to effectively diagnose any issues users may report. Through this function I was able to have the script report what updates it had found (if any), when it had last displayed (important later) and how the user interacted with it (dismissed or restarted).

##*===============================================
##* VARIABLE DECLARATION
##*===============================================
$regLocation = 'HKCU:\Software\IT Services\Restart Popup'
$regproperty = 'Clicked'
$logFileName = 'Restart Popup.log'
$logFolderPath = "$env:temp\Restart Popup\"
$logPath = $logFolderPath + $logFileName
$MaxLogSizeInKB = 2048
##*===============================================

Function Add-ScriptEvent {
<# .SYNOPSIS
Adds to a log file that's compatible with CMTrace.exe
.DESCRIPTION
Adds to a log file that's compatible with CMTrace.exe
.PARAMETER Log File
Location for log file, by default it uses $logpath
.PARAMETER Message
The message to add to the log file.
.PARAMETER Component
Source of the event.
.PARAMETER Severity
The severity of the event, 1-Information, 2-Warning, 3-Error.
.EXAMPLE
Add-ScriptEvent -message 'Starting Script' -component 'Main' -severity 1
.EXAMPLE
Add-ScriptEvent -message 'Error occurred getting content' -component 'Get-ItemProperty' -severity 3
#>

[CmdletBinding()]
Param(
    [parameter(Mandatory=$False)]
    [String]$LogLocation = $logPath,
    [parameter(Mandatory=$True)]
    [String]$Message,
    [parameter(Mandatory=$True)]
    [String]$Component,
    [parameter(Mandatory=$True)]
    [ValidateRange(1,3)]
    [Single]$Severity
    )
    BEGIN {}
    PROCESS {

        ## Obtain UTC offset
    
        $DateTime = New-Object -ComObject WbemScripting.SWbemDateTime 
        $DateTime.SetVarDate($(Get-Date))
        $UtcValue = $DateTime.Value
        $UtcOffset = $UtcValue.Substring(21, $UtcValue.Length - 21)

        ## Create the line to be logged
    
        $LogLine =  "<![LOG[$Message]LOG]!>" +`
                    "<time=`"$(Get-Date -Format HH:mm:ss.fff)$($UtcOffet)`" " +`
                    "date=`"$(Get-Date -Format M-d-yyyy)`" " +`
                    "component=`"$Component`" " +`
                    "context=`"$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)`" " +`
                    "type=`"$Severity`" " +`
                    "thread=`"$([Threading.Thread]::CurrentThread.ManagedThreadId)`" " +`
                    "file=`"`">"

        ## Write the line to the passed log file
        Add-Content -Path $LogLocation -Value $LogLine     
    }

    END{}
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


Get-WindowsUpdateStatus
Retrieves information on local windows updates with status 'In Progress installing'. The script looks for 3 specific status codes in order to do this (Get-WmiObject), then passes any found through to ComObject so that a date can be retrieved.

Function Get-WindowsUpdateStatus {
<# .SYNOPSIS
Retrieves information on local windows updates with status 'In Progress installing'.
.DESCRIPTION
Get-WindowsUpdateStatus retrieves a list of local windows updates filtered via
'ResultCode 1' for 'In Progress installing' and the source 'CcmExec' or 'AutomaticUpdates'.

.EXAMPLE
Get-WindowsUpdateStatus
.EXAMPLE
Get-WindowsUpdateStatus | Where-Object { ($_.Date -le (Get-Date).AddDays(-1)) }
#>

[CmdletBinding()]
Param()
    BEGIN {}
    PROCESS {
        
        Try {

            ## Look for windows updates with status: 8 (pending soft reboot), 9 (pending hard reboot), 10 (wait reboot)
            $CCMUpdate = Get-WmiObject -Query 'SELECT * FROM CCM_SoftwareUpdate' -Namespace 'ROOT\ccm\ClientSDK'  | Where-Object { $_.EvaluationState -eq 8 -or $_.EvaluationState -eq 9 -or $_.EvaluationState -eq 10 }
            ## Checks a windows update is found before proceeding
            $CCMUpdateCheck = If(@($CCMUpdate).length -ne 0) { $True } Else { $False }

            #If an update is found
            If ($CCMUpdateCheck) {
                
                #Cycle through each one
                ForEach ($update in $CCMUpdate) {
                    
                    $updatename = $update.Name
                    Write-Verbose -Message "WmiObject Windows Update pending reboot found Name: '$updatename' passing through to ComObject"

                    ## WmiObject finds a windows update, now ComObject can acquire the date for 'Cumulative update' only. ResultCode '1' = In Progress installing.
                    $Session = New-Object -ComObject "Microsoft.Update.Session"
                    $Searcher = $Session.CreateUpdateSearcher()
                    $historyCount = $Searcher.GetTotalHistoryCount()
                    $ComObject = $Searcher.QueryHistory(0, $historyCount) | Where-Object { ($_.ResultCode -eq '1') -and ($_.Title -match $update.ArticleID) -and ($_.Title -notmatch 'Adobe Flash Player' ) -and ($_.ClientApplicationID -eq 'CcmExec' -or $_.ClientApplicationID -eq 'AutomaticUpdates') }
                    
                    If ($ComObject) {
                        $ComObjectTitle = $ComObject.Title
                        $ComObjectDate = $ComObject.Date
                        Write-Verbose -Message "ComObject Windows Update pending reboot found Name: '$ComObjectTitle', Date/Time: '$ComObjectDate'"
                        $ComObject
                    }
                    Else {
                        Write-Verbose -Message "No ComObject match for Name: '$updatename'"
                    }

                }
            }
            Else {
                Write-Verbose -Message 'No WmiObject Windows Update pending reboot found, nothing to pass through to ComObject'
            }

        }
        Catch {
            Write-Verbose -Message 'The following error occurred'
            Write-Verbose -Message $_
        }

    }

    END{}
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


Get-RegKey
A Registry key was used to record the date when a user had interacted with the popup, so that it would not be displayed more frequently than every 3 days.

Function Get-RegKey {
<# .SYNOPSIS
Retrieves Registry Key date from when popup interacted with
and count days to current.
.DESCRIPTION
Get-RegKey retrieves the date from the Registry Key
at HKEY_CURRENT_USER\Software\IT Services\Restart Popup and counts
the number of days from them until current (Get-Date).
.EXAMPLE
Get-RegKey
#>

[CmdletBinding()]
Param(
    [Int]$daysinterval = '3'
    )
    BEGIN {}
    PROCESS {

            If ($days = Get-ItemProperty -Path $regLocation -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $regproperty ) { 

                If ($days -as [DateTime]) {
                    Write-Verbose -Message 'Registry Key date format is correct'

                    If (!(($days | New-TimeSpan -End (Get-Date)).Days -le $daysinterval)) {
                        Write-Verbose -Message 'Registry Key date is not within last 3 days, so will be displayed'
                        Add-ScriptEvent  -Message  'Registry Key date is not within last 3 days, so will be displayed'  -Component  'Registry'  -Severity  1
                        $True
                        }
                        Else {
                            Write-Verbose -Message 'Registry Key date is within last 3 days, so will not be displayed'
                            Add-ScriptEvent -Message  'Registry Key date is within last 3 days, so will not be displayed'  -Component   'Registry'  -Severity  1
                            $False
                        }

                }
                Else {
                    Write-Verbose -Message 'Registry Key date format is not correct, deleting Key, so will be displayed'
                    Remove-ItemProperty -Path $regLocation -Name $regproperty
                    Add-ScriptEvent -Message 'Registry Key date format is not correct, deleting Key, so will be displayed'   -Component   'Registry'  -Severity  3
                    $True
                }
            
            }
            Else {
                Write-Verbose -Message 'Registry Key not found, so will be displayed as assumed not yet been interacted with'
                Add-ScriptEvent  -Message   'Registry Key not found, so will be displayed as assumed not yet been interacted with'   -Component   'Registry'   -Severity  2
                $True
            }
    }
    END{}
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


Write-RegKey
Writes the current date to the Registry once the user interacts with the popup.

Function Write-RegKey {
<# .SYNOPSIS
Write Registry Key date.
.DESCRIPTION
Write-RegKey writes the current date to the 'Clicked' Registry Key
at HKEY_CURRENT_USER\Software\IT Services\Restart Popup when the user
interacts with the popup
.EXAMPLE
Write-RegKey
#>

[CmdletBinding()]
Param()
    BEGIN {}
    PROCESS {
        
        Try {
            $everythingOK=$True
            New-Item -Path $regLocation -Force | Out-Null -ErrorAction Stop
            New-ItemProperty -Path $regLocation -Name $regproperty -Value (Get-Date -F 'dd/MM/yyyy') -Force | Out-Null -ErrorAction Stop
        }
        Catch {
            $everythingOK = $False
            Write-Verbose -Message 'The following error occurred'
            Write-Verbose -Message $_
            Add-ScriptEvent -Message "The following error occurred $_"  -Component   'Registry'   -Severity   2
        }
    }
    END{}
}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


Show-Popup
This is the core component of the restart popup and brings everything together with the necessary logic. After setting up the log file and checking the Registry key date is not true (so has not displayed within the last 3 days), it looks for any pending windows updates installed with the below criteria:
  • Install date within last 1 - 2 days, show popup = true
  • Install date within last 2 - 7 days, show popup = false
  • Install date greater than 7 days, show popup = true
 If an update meets this criteria then the popup is activated and the XAML part of the script kicks in to render it. The 2 buttons allow for it to either be dismissed or the PC restarted, with the former this script is ran via a Schedule Task every hour so it will perform the logic to determine if/when it should next be displayed.

Function Show-Popup {
<# .SYNOPSIS
Shows Restart Popup based upon pending Windows Updates.
.DESCRIPTION
Shows Restart Popup based upon pending Windows Updates 1 day after found,
then again 7 days later for every 3 days until restarted. Will not
display more frequently then every 3 days.
.EXAMPLE
Show-Popup
#>

[CmdletBinding()]
Param()
    BEGIN {}
    PROCESS {

        ## LOG
        Write-Verbose -Message "Error log will be $logpath"

        ## Test if log exists, if not create log.
        If (!(Test-Path $logpath)) {
            Write-Verbose -Message "Log file missing, creating new log at $logpath"
            New-Item -Path $logFolderPath -Name $logFileName -ItemType 'File' -Force | Out-Null
        }
        Else {

            ## Log file exists, check size, if over 2MB then rename logfile and create a new one.

            If ((Get-Item -Path $logPath).Length/1KB -gt $MaxLogSizeInKB) {
                Write-Verbose -Message "Log file exceeds $MaxLogSizeInKB, renaming log."
                $log = $logPath
                $oldlogfile = $log.Replace('.log', '.lo_')
        
                If (Test-Path $oldlogfile) {
                    Remove-Item -Path $oldlogfile
                }

                Rename-Item -Path $logPath -NewName ($log.Replace('.log', '.lo_')) -Force
                New-Item -Path $logFolderPath -Name $logFileName -ItemType 'File' -Force | Out-Null
            }

        }


        Add-ScriptEvent -Message 'Starting Restart Popup Script' -Component 'Main' -Severity 1


        ## REGISTRY KEY DATE
        If (($windowsupdatestatus = Get-WindowsUpdateStatus)) {
    
            ## Looks for 1 day Windows Update pending a reboot
            Write-Verbose -Message 'Checking for 1 day pending Windows Update'
            If (($windowsupdatestatus | Where-Object { ($_.Date -le (Get-Date).AddDays(-1)) -and ($_.Date -ge (Get-Date).AddDays(-2)) })) {
        
                ForEach ($update1 in $windowsupdatestatus) {
            
                    $title1 = $update1.Title
                    $date1 = $update1.Date
                    Write-Verbose -Message "1 day Windows Update pending reboot found, Name: '$title1', Date/Time: '$date1'"
                    Add-ScriptEvent -Message "1 day Windows Update pending reboot found, Name: '$title1', Date/Time: '$date1'" -Component 'Update-Checker' -Severity 1
        
                }

                ## Check popup has not been Clicked in last 3 days
                If (Get-RegKey) {
                    Write-Verbose -Message 'Setting showpopup variable to true'
                    $showpopup = $True
                }
                Else {
                    Write-Verbose -Message 'Setting showpopup variable to false'
                    $showpopup = $False
                }

            }
            Else {
                Write-Verbose -Message 'No 1 day pending Windows Update found'
            }
            
            ## Looks for 7+ days Windows Update pending a reboot
            Write-Verbose -Message 'Checking for 7+ days pending Windows Update'
            If (($windowsupdatestatus | Where-Object { ($_.Date -le (Get-Date).AddDays(-7)) })) {
        
                ForEach ($update7 in $windowsupdatestatus) {
            
                    $title7 = $update7.Title
                    $date7 = $update7.Date
                    Write-Verbose -Message "7+ days Windows Update pending reboot found, Name: '$title7', Date/Time: '$date7'"
                    Add-ScriptEvent -Message "7+ days Windows Update pending reboot found, Name: '$title7', Date/Time: '$date7'" -Component 'Update-Checker' -Severity 1
        
                }

                ## Check popup has not been Clicked in last 3 days
                If (Get-RegKey) {
                    Write-Verbose -Message 'Setting showpopup variable to true'
                    $showpopup = $True
                }
                Else {
                    Write-Verbose -Message 'Setting showpopup variable to false'
                    $showpopup = $False
                }
            }
            Else {
                Write-Verbose -Message 'No 7+ days pending Windows Update found'
            }

            ## Looks for 2-6 days Windows Update pending a reboot (when popup will not display)
            If (($update1 -eq $NULL) -and ($update7 -eq $NULL)) {
                
                Write-Verbose -Message 'Checking for 2-6 days pending Windows Update'
                If (($windowsupdatestatus | Where-Object { ($_.Date -le (Get-Date).AddDays(-2)) -and ($_.Date -ge (Get-Date).AddDays(-7)) })) {
                
                    ForEach ($update2 in $windowsupdatestatus) {
            
                        $title2 = $update2.Title
                        $date2 = $update2.Date
                        Write-Verbose -Message "2-6 days Windows Update pending reboot found, popup will not be displayed, Name: '$title2', Date/Time: '$date2'"
                        Add-ScriptEvent -Message "2-6 days Windows Update pending reboot found, popup will not be displayed, Name: '$title2', Date/Time: '$date2'" -Component 'Update-Checker' -Severity 1
        
                    }

                }
                Else {
                    Write-Verbose -Message 'No 2-6 days pending Windows Update found'
                }
            }

        }
        Else {
            Write-Verbose -Message 'No pending Windows Update found'
            Add-ScriptEvent -Message 'No pending Windows Update found' -Component 'Update-Checker' -Severity 1
        }


        # ACTIVATE NOTIFICATION IF PENDING REBOOT
        If ($showpopup) {

            # Log when popup displayed
            Write-Verbose -Message 'Getting timestamp and logged-on user for when popup displayed'
            $popuptimestamp = Get-Date -F "dd/MM/yyyy HH:mm:ss"
            Add-ScriptEvent -Message "Popup displayed to $env:username" -Component 'Popup' -Severity 1

            # Add the required assemblies
            Add-Type -AssemblyName PresentationFramework,System.Windows.Forms

            # Add a super simple form that contains a just with text within a grid
            [xml]$xaml =  '<Window
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            Name="window" WindowStyle="None" Height="105" Width="360" AllowsTransparency="True" Opacity="0.97"
            ResizeMode="NoResize" ShowInTaskbar="False">
            <Grid Name="grid" Height="400" Width="400" Background="#1f1f1f" >

                <Label Name="label" Content="IT Services" Foreground="White" FontSize="17" Margin="5,0,0,0" />

                <Label Name="label2" Content="Your PC requires a restart to install pending updates." Foreground="White" FontSize="14" Height="345" Margin="5,0,0,0" />
   
                <Button Name="restart" Content="Restart" FontSize="15" Width="110" Height="38" Margin="70,58,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Foreground="White">
                <Button.Template>
                <ControlTemplate TargetType="{x:Type Button}">
                <Border x:Name="bdr_main" Margin="4" Background="DimGray">
                    <ContentPresenter VerticalAlignment="Center" HorizontalAlignment="Center" ContentSource="Content" />
                </Border>
                </ControlTemplate>
                </Button.Template>

                </Button><Button Name="dismiss" Content="Dismiss" FontSize="15" Width="110" Height="38" Margin="200,58,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Foreground="White">
                <Button.Template>
                <ControlTemplate TargetType="{x:Type Button}">
                <Border x:Name="bdr_main" Margin="4" Background="DimGray">
                    <ContentPresenter VerticalAlignment="Center" HorizontalAlignment="Center" ContentSource="Content" />
                </Border>
                </ControlTemplate>
                </Button.Template>
                </Button>

            </Grid>
            </Window>'


            # Turn the xaml into a form
            $script:window = [Windows.Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))
            $xaml.SelectNodes("//*[@Name]") | ForEach-Object { Set-Variable -Name ($_.Name) -Value $window.FindName($_.Name) -Scope Script }

            # Connect Buttons to Controls
            $dismissbutton = $window.FindName('dismiss')
            $restartbutton = $window.FindName('restart')

            # Dismiss Button Event
            $dismissbutton.Add_Click({
                Write-Verbose -Message 'User clicked Dismiss button'
                Add-ScriptEvent -Message "Popup dismissed by $env:username" -Component 'Popup' -Severity 1
                Write-RegKey
                $window.Close()
            })

            # Restart Button Event
            $restartbutton.Add_Click({
                Write-Verbose -Message 'User clicked Restart button'
                Add-ScriptEvent -Message "Popup clicked to restart by $env:username" -Component 'Popup' -Severity 1
                Write-RegKey
                $window.Close()
                Restart-Computer
            })

            $window.Left = $([System.Windows.SystemParameters]::WorkArea.Width-$window.Width)
            $window.Top = $([System.Windows.SystemParameters]::WorkArea.Height-$window.Height)
            $window.Topmost = $true
            $window.ShowDialog() | Out-Null
        }
        Else {
        }

        Add-ScriptEvent -Message 'Completed Restart Popup Script' -Component 'Main' -Severity 1

        }
        END {}

}
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Restart Popup.ps1

No comments:

Post a Comment