Deploying HP BIOS Updates – a real world example

Not so long ago HP published a customer advisory listing a number of their models that need to be on the latest BIOS release to be upgraded to Windows 10 2004. Since we were getting ready to rollout 20H2 we encountered some affected models in piloting, which prompted me to find that advisory and then get the BIOS updated on affected devices.

To be honest, until now we’ve never pushed out BIOS updates to anyone, but to get these devices updated to 20H2 we now had no choice. In this post I’m just going to share how we did that. For us, good user experience is critical but finding the balance between keeping devices secure and up-to-date without being too disruptive to the user can be a challenge!

First off was to create a script that could update the BIOS on any supported HP workstation, without needing to package and distribute BIOS update content. I know other equally handsome community members have shared some great scripts and solutions for doing BIOS updates, but I decided to create my own in this instance to meet our particular requirements and afford a bit more control over the update process. I considered using the HP Client Management Script Library which seems purpose-built for this kind of task and is a great resource, but I preferred not to have the dependency of an external PowerShell module and its requirements.

I published a version of this script in Github here. The script does the following:

  • Creates a working directory under ProgramData
  • Disables the IE first run wizard which causes a problem for the Invoke-WebRequest cmdlet when running in a context where IE hasn’t been initialised
  • Checks the HP Image Assistant (HPIA) web page for the latest version and downloads it
  • Extracts and runs the HPIA to identify the latest applicable BIOS update (if any)
  • If a BIOS update is available, downloads and extracts the softpaq
  • Checks if a BIOS password has been set, if so creates an encrypted password file as required by the firmware update utility
  • Runs the firmware update utility to stage the update
  • Everything is logged to the working directory and the logs are uploaded to Azure blob storage upon completion, or if something fails, so we can review them without requiring remote access to the user’s computer

It all runs silently without any user interaction and it can be run on any HP model that the HPIA supports.

In Production however, we used a slightly modified version of this script. Since there was the possibility that there could be unknown BIOS passwords in use out there, we decided not to try to flash the BIOS using an encrypted password file, but instead try to remove the BIOS password altogether (temporarily!) When the BIOS update is staged it simply copies the password file to the staging volume – it doesn’t check whether the password is correct or not. If it is not correct, the user would then be asked for the correct password when the BIOS is flashed and that is not cool! Removing the password meant that the user could never be unexpectedly prompted for the password in the event that the provided password file was incorrect. Of course, to remove the password you also have to know the password, so we tried the ones we knew and if they worked, great, if they didn’t, the script would simply exit out as a failsafe.

We have a compliance baseline deployed with MEMCM that sets the BIOS password on any managed workstation that does not have one set, so after the machine rebooted, the BIOS flashed and machine starts up again, before long the CB would run and set the password again.

Doing this also meant that we needed to ensure the computer was restarted asap after the update was staged – and for another reason as well – Bitlocker encryption is suspended until the update is applied and the machine restarted.

Because we didn’t want to force the update on users and force a restart on them, we decided to package the script as an application in MEMCM. This meant a couple of things:

  • We could put a nice corporate logo on the app and make it available for install in the Software Center
  • We could handle the return codes with custom actions. In this case, we are expecting the PowerShell script to exit successfully with code 0, and when it does we’ve set that code to be a soft reboot so that MEMCM restart notifications are then displayed to the user.

As a detection method, the following PowerShell code was used. This simply detects if the last write time of the log file has changed within the last hour, if it has, it’s installed. Longer than an hour and it’s available for install again if needed.

$Log = Get-ChildItem C:\ProgramData\Contoso\HP_BIOS_Update -Recurse -Include HP_BIOS_Update.log -ErrorAction SilentlyContinue | 
    where {([DateTime]::Now - $_.LastWriteTime).TotalHours -le 1}
If ($Log)
{
    Write-Host "Installed"
}

We then deployed the application with an available deployment. We communicated with the users directly to inform them a BIOS update needed to be installed on their device in order to remain secure and up-to-date, and directed them to the Software Center to install it themselves.

We also prepared collections in SCCM using query-based membership rules to identify the machines that were affected by the HP advisory, and an SQL query to find the same information and pull the full user name and email address from inventoried data.

The script does contain the BIOS password in clear text which, or course, may not meet your security requirements, although for us this password is not really that critical – it’s just there to help prevent the user from making unauthorized changes in the BIOS. In our Production script though, we simply converted these to base64 before adding them to the script to at least provide some masking. But for greater security you could consider storing the password in Azure key vault and fetch it at run time with a web request, for example.

If you wish to use the script in your own environment, you’ll need to change the location of the working directory as you desire. Additionally if you wish to upload the log files to an Azure storage container, you’ll need to have or create a container and add the URL and the SAS token string to the script, or else just comment out the Upload-LogFilesToAzure function where it’s used. I’m a big fan of sending log files to Azure storage especially during this season where many are working from home and may not be corporate connected. You can just use Azure Storage Explorer to download the log files which will open up in CMTrace if that’s your default log viewer.

Hope this is helpful to someone! The PS script is below.

#####################
## HP BIOS UPDATER ##
#####################
# Params
$HPIAWebUrl = "https://ftp.hp.com/pub/caps-softpaq/cmit/HPIA.html" # Static web page of the HP Image Assistant
$BIOSPassword = "MyPassword"
$script:ContainerURL = "https://mystorageaccount.blob.core.windows.net/mycontainer" # URL of your Azure blob storage container
$script:FolderPath = "HP_BIOS_Updates" # the subfolder to put logs into in the storage container
$script:SASToken = "mysastoken" # the SAS token string for the container (with write permission)
$ProgressPreference = 'SilentlyContinue' # to speed up web requests
################################
## Create Directory Structure ##
################################
$RootFolder = $env:ProgramData
$ParentFolderName = "Contoso"
$ChildFolderName = "HP_BIOS_Update"
$ChildFolderName2 = Get-Date Format "yyyy-MMM-dd_HH.mm.ss"
$script:WorkingDirectory = "$RootFolder\$ParentFolderName\$ChildFolderName\$ChildFolderName2"
try
{
[void][System.IO.Directory]::CreateDirectory($WorkingDirectory)
}
catch
{
throw
}
# Function write to a log file in ccmtrace format
Function script:Write-Log {
param (
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter()]
[ValidateSet(1, 2, 3)] # 1-Info, 2-Warning, 3-Error
[int]$LogLevel = 1,
[Parameter(Mandatory = $true)]
[string]$Component,
[Parameter(Mandatory = $false)]
[object]$Exception
)
$LogFile = "$WorkingDirectory\HP_BIOS_Update.log"
If ($Exception)
{
[String]$Message = "$Message" + "$Exception"
}
$TimeGenerated = "$(Get-Date Format HH:mm:ss).$((Get-Date).Millisecond)+000"
$Line = '<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="" type="{4}" thread="" file="">'
$LineFormat = $Message, $TimeGenerated, (Get-Date Format MMddyyyy), $Component, $LogLevel
$Line = $Line -f $LineFormat
# Write to log
Add-Content Value $Line Path $LogFile ErrorAction SilentlyContinue
}
# Function to upload log file to Azure Blob storage
Function Upload-LogFilesToAzure {
$Date = Get-date Format "yyyy-MM-dd_HH.mm.ss"
$HpFirmwareUpdRecLog = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.log Recurse ErrorAction SilentlyContinue
$HPBIOSUPDRECLog = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC64.log Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRecLog)
{
$File = $HpFirmwareUpdRecLog
}
ElseIf ($HPBIOSUPDRECLog)
{
$File = $HPBIOSUPDRECLog
}
Else{}
If ($File)
{
$Body = Get-Content $($File.FullName) Raw ErrorAction SilentlyContinue
If ($Body)
{
$URI = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File.Name)$SASToken"
$Headers = @{
'x-ms-content-length' = $($File.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI Method PUT Headers $Headers Body $Body ErrorAction SilentlyContinue
}
}
$File2 = Get-Item $WorkingDirectory\HP_BIOS_Update.log ErrorAction SilentlyContinue
$Body2 = Get-Content $($File2.FullName) Raw ErrorAction SilentlyContinue
If ($Body2)
{
$URI2 = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File2.Name)$SASToken"
$Headers2 = @{
'x-ms-content-length' = $($File2.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI2 Method PUT Headers $Headers2 Body $Body2 ErrorAction SilentlyContinue
}
}
Write-Log Message "#######################" Component "Preparation"
Write-Log Message "## Starting BIOS update run ##" Component "Preparation"
Write-Log Message "#######################" Component "Preparation"
#################################
## Disable IE First Run Wizard ##
#################################
# This prevents an error running Invoke-WebRequest when IE has not yet been run in the current context
Write-Log Message "Disabling IE first run wizard" Component "Preparation"
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft" Name "Internet Explorer" Force
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer" Name "Main" Force
$null = New-ItemProperty Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer\Main" Name "DisableFirstRunCustomize" PropertyType DWORD Value 1 Force
##########################
## Get latest HPIA Info ##
##########################
Write-Log Message "Finding info for latest version of HP Image Assistant (HPIA)" Component "DownloadHPIA"
try
{
$HTML = Invoke-WebRequest Uri $HPIAWebUrl ErrorAction Stop
}
catch
{
Write-Log Message "Failed to download the HPIA web page. $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
$HPIASoftPaqNumber = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).outerText
$HPIADownloadURL = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).href
$HPIAFileName = $HPIADownloadURL.Split('/')[-1]
Write-Log Message "SoftPaq number is $HPIASoftPaqNumber" Component "DownloadHPIA"
Write-Log Message "Download URL is $HPIADownloadURL" Component "DownloadHPIA"
###################
## Download HPIA ##
###################
Write-Log Message "Downloading the HPIA" Component "DownloadHPIA"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$HPIAFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadHPIA" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $HPIADownloadURL Destination $WorkingDirectory\$HPIAFileName Asynchronous DisplayName "$HPIAFileName" Description "HPIA download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadHPIA"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadHPIA"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadHPIA"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the HPIA: $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
##################
## Extract HPIA ##
##################
Write-Log Message "Extracting the HPIA" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\$HPIAFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\HPIA\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\HPIA\HPImageAssistant.exe)
{
Write-Log Message "Extraction complete" Component "Analyze"
}
Else
{
Write-Log Message "HPImageAssistant not found!" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to extract the HPIA: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
##############################################
## Analyze available BIOS updates with HPIA ##
##############################################
Write-Log Message "Analyzing system for available BIOS updates" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\HPIA\HPImageAssistant.exe WorkingDirectory $WorkingDirectory ArgumentList "/Operation:Analyze /Category:BIOS /Selection:All /Action:List /Silent /ReportFolder:$WorkingDirectory\Report" NoNewWindow PassThru Wait ErrorAction Stop
If ($Process.ExitCode -eq 0)
{
Write-Log Message "Analysis complete" Component "Analyze"
}
elseif ($Process.ExitCode -eq 256)
{
Write-Log Message "The analysis returned no recommendation. No BIOS update is available at this time" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
Exit 0
}
elseif ($Process.ExitCode -eq 4096)
{
Write-Log Message "This platform is not supported!" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
throw
}
Else
{
Write-Log Message "Process exited with code $($Process.ExitCode). Expecting 0." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to start the HPImageAssistant.exe: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
# Read the XML report
Write-Log Message "Reading xml report" Component "Analyze"
try
{
$XMLFile = Get-ChildItem Path "$WorkingDirectory\Report" Recurse Include *.xml ErrorAction Stop
If ($XMLFile)
{
Write-Log Message "Report located at $($XMLFile.FullName)" Component "Analyze"
try
{
[xml]$XML = Get-Content Path $XMLFile.FullName ErrorAction Stop
$Recommendation = $xml.HPIA.Recommendations.BIOS.Recommendation
If ($Recommendation)
{
$CurrentBIOSVersion = $Recommendation.TargetVersion
$ReferenceBIOSVersion = $Recommendation.ReferenceVersion
$DownloadURL = "https://" + $Recommendation.Solution.Softpaq.Url
$SoftpaqFileName = $DownloadURL.Split('/')[-1]
Write-Log Message "Current BIOS version is $CurrentBIOSVersion" Component "Analyze"
Write-Log Message "Recommended BIOS version is $ReferenceBIOSVersion" Component "Analyze"
Write-Log Message "Softpaq download URL is $DownloadURL" Component "Analyze"
}
Else
{
Write-Log Message "Failed to find a BIOS recommendation in the XML report" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to parse the XML file: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
Else
{
Write-Log Message "Failed to find an XML report." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to find an XML report: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
###############################
## Download the BIOS softpaq ##
###############################
Write-Log Message "Downloading the Softpaq" Component "DownloadBIOSUpdate"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$SoftpaqFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadBIOSUpdate" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $DownloadURL Destination $WorkingDirectory\$SoftpaqFileName Asynchronous DisplayName "$SoftpaqFileName" Description "BIOS update download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadBIOSUpdate"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadBIOSUpdate"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadBIOSUpdate"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the BIOS update: $($_.Exception.Message)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#########################
## Extract BIOS Update ##
#########################
Write-Log Message "Extracting the BIOS Update" Component "ExtractBIOSUpdate"
$BIOSUpdateDirectoryName = $SoftpaqFileName.Split('.')[0]
try
{
$Process = Start-Process FilePath $WorkingDirectory\$SoftpaqFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\$BIOSUpdateDirectoryName\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
$HpFirmwareUpdRec = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.exe Recurse ErrorAction SilentlyContinue
$HPBIOSUPDREC = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC.exe Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRec)
{
$BIOSExecutable = $HpFirmwareUpdRec
}
ElseIf ($HPBIOSUPDREC)
{
$BIOSExecutable = $HPBIOSUPDREC
}
Else
{
Write-Log Message "BIOS update executable not found!" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Extraction complete" Component "ExtractBIOSUpdate"
}
catch
{
Write-Log Message "Failed to extract the softpaq: $($_.Exception.Message)" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#############################
## Check for BIOS password ##
#############################
try
{
$SetupPwd = (Get-CimInstance Namespace ROOT\HP\InstrumentedBIOS ClassName HP_BIOSPassword Filter "Name='Setup Password'" ErrorAction Stop).IsSet
If ($SetupPwd -eq 1)
{
Write-Log Message "The BIOS has a password set" Component "BIOSPassword"
$BIOSPasswordSet = $true
}
Else
{
Write-Log Message "No password has been set on the BIOS" Component "BIOSPassword"
}
}
catch
{
Write-Log Message "Unable to determine if a BIOS password has been set: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
##########################
## Create password file ##
##########################
If ($BIOSPasswordSet)
{
Write-Log Message "Creating an encrypted password file" Component "BIOSPassword"
$HpqPswd = Get-ChildItem Path $WorkingDirectory Include HpqPswd.exe Recurse ErrorAction SilentlyContinue
If ($HpqPswd)
{
try
{
$Process = Start-Process FilePath $HpqPswd.FullName WorkingDirectory $WorkingDirectory ArgumentList "-p""$BIOSPassword"" -f.\password.bin -s" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\password.bin)
{
Write-Log Message "File successfully created" Component "BIOSPassword"
}
Else
{
Write-Log Message "Encrypted password file could not be found!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to create an encrypted password file: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
else
{
Write-Log Message "Failed to locate HP password encryption utility!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
###########################
## Stage the BIOS update ##
###########################
Write-Log Message "Staging BIOS firmware update" Component "BIOSFlash"
try
{
If ($BIOSPasswordSet)
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -p.\password.bin -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
Else
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
If ($Process.ExitCode -eq 3010)
{
Write-Log Message "The update has been staged. The BIOS will be updated on restart" Component "BIOSFlash"
}
Else
{
Write-Log Message "An unexpected exit code was returned: $($Process.ExitCode)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to stage BIOS update: $($_.Exception.Message)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "This BIOS update run is complete. Have a nice day!" Component "Completion"
UploadLogFilesToAzure

Get Program Execution History from a ConfigMgr Client with PowerShell

Have you ever been in the situation where something unexpected happens on a users computer and people start pointing their fingers at the ConfigMgr admin and asking “has anyone deployed something with SCCM?” Well, I decided to write a PowerShell script to retrieve the execution history for ConfigMgr programs on a local or remote client. This gives clear visibility of when and which deployments such as applications/programs/task sequences have run on the client and hopefully acquit you (or prove you guilty!)

Program execution history can be found in the registry but it doesn’t contain the name of the associated package, so I joined that data with software distribution data from WMI to give a better view.

You can run the script against the local machine, or a remote machine if you have PS remoting enabled. You can also run it against multiple machines at the same time and combine the data if desired. I recommend to pipe the results to grid view.

Get-CMClientExecutionHistory -Computername PC001,PC002 | Out-GridView
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$false,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)]
[string[]]$ComputerName = $env:COMPUTERNAME
)
Begin
{
$Code = {
# Get Execution History from registry, and package details from WMI
$ExecutionHistoryKey = "HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client\Software Distribution\Execution History"
$ContextKeys = Get-ChildItem $ExecutionHistoryKey | Select ExpandProperty PSChildName
foreach ($ContextKey in $ContextKeys)
{
If ($ContextKey -eq "System")
{
$ContextKey = "Machine"
}
Else
{
$ContextKey = $ContextKey.Replace('','_')
}
[array]$SoftwareDistribution += Get-CimInstance Namespace ROOT\ccm\Policy\$ContextKey ClassName CCM_SoftwareDistribution
}
# Create a datatable to hold the results
$DataTable = New-Object System.Data.DataTable
[void]$DataTable.Columns.Add("ComputerName")
[void]$DataTable.Columns.Add("PackageName")
[void]$DataTable.Columns.Add("PackageID")
[void]$DataTable.Columns.Add("ProgramName")
[void]$DataTable.Columns.Add("DeploymentStatus")
[void]$DataTable.Columns.Add("Context")
[void]$DataTable.Columns.Add("State")
[void]$DataTable.Columns.Add("RunStartTime")
[void]$DataTable.Columns.Add("SuccessOrFailureCode")
[void]$DataTable.Columns.Add("SuccessOrFailureReason")
foreach ($ContextKey in $ContextKeys)
{
If ($ContextKey -ne "System")
{
# Get user context if applicable
$SID = New-Object Security.Principal.SecurityIdentifier ArgumentList $ContextKey
$Context = $SID.Translate([System.Security.Principal.NTAccount])
}
Else
{
$Context = "Machine"
}
$SubKeys = Get-ChildItem "$ExecutionHistoryKey\$ContextKey"
Foreach ($SubKey in $SubKeys)
{
$Items = Get-ChildItem $SubKey.PSPath
Foreach ($Item in $Items)
{
$PackageInfo = $SoftwareDistribution | Where {$_.PKG_PackageID -eq $SubKey.PSChildName -and $_.PRG_ProgramName -eq $Item.GetValue("_ProgramID")} | Select First 1
If ($PackageInfo)
{
$PackageName = $PackageInfo.PKG_Name
$DeploymentStatus = "Active"
}
Else
{
$PackageName = "-Unknown-"
$DeploymentStatus = "No longer targeted"
}
[void]$DataTable.Rows.Add($using:Computer,$PackageName,$SubKey.PSChildName,$Item.GetValue("_ProgramID"),$DeploymentStatus,$Context,$Item.GetValue("_State"),$Item.GetValue("_RunStartTime"),$Item.GetValue("SuccessOrFailureCode"),$Item.GetValue("SuccessOrFailureReason"))
}
}
}
$DataTable.DefaultView.Sort = "RunStartTime DESC"
$DataTable = $DataTable.DefaultView.ToTable()
Return $DataTable
}
}
Process
{
foreach ($Computer in $ComputerName)
{
If ($Computer -eq $env:COMPUTERNAME)
{
$Result = Invoke-Command ScriptBlock $Code
}
Else
{
$Result = Invoke-Command ComputerName $Computer HideComputerName ScriptBlock $Code ErrorAction Continue
}
$Result | Select ComputerName,PackageName,PackageID,ProgramName,DeploymentStatus,Context,State,RunStartTime,SuccessOrFailureCode,SuccessOrFailureReason
}
}
End
{
}

Using Windows 10 Toast Notifications with ConfigMgr Application Deployments

When deploying software with ConfigMgr, the ConfigMgr client can create a simple “New software is available” notification to inform the user that something new is available to install from the Software Center. But this notification is not overly descriptive. You might wish to provide a more detailed notification with a description of the software, why the user should install it, the installation deadline etc. For Windows 10, we can do that simply by disabling the inbuilt notifications on the deployment and creating our own custom toast notifications instead.

The Notification

Consider the examples below.

Here I have created a simple toast notification with the name of the software, what it does, what it is needed for, and a simple instruction to close Outlook before installing. The user can then choose to install it now – and clicking on that button will simply open the Software Center to that application via it’s sharing link. If they click Another time… the notification goes away for now, and if they dismiss it, it will move to the Action Center.

Title Only

In this version, I’ve added a logo instead of a title…

Image Only

…and in this version, I’ve added both.

Title and Image

If the deployment has a deadline, you can state the deadline in the notification as well as tell the user how long they have left before the deadline is reached.

Image with Deadline

Clicking Install now opens that app in the Software Center where the user can go ahead and install it…

Software Center

The big gotcha (for now) is that this only works with Application deployments, and you need to be running ConfigMgr 1706 or later. Please, Microsoft, make sharing links possible for other deployments (packages/programs, task sequences) too!

The client machines also need to be running Windows 10 Anniversary Update or later for the notification to work properly.

The Magic

So how does this work? Well, first we need to disable the inbuilt notifications on the application deployment, so set that to Display in Software Center, and only show notifications for computer restarts in the deployment type on the User Experience tab.

Next, we create a compliance item and compliance baseline which will display the notification. Target the compliance baseline at the same collection/s you are targetting your application.

The compliance item will have a PowerShell discovery script and remediation script. The discovery script will simply detect whether the software has been installed and report compliance if it is. The remediation script contains the code that displays the notification, and will only run if the discovery script does not report compliance, ie the software is not yet installed.

The Code

For the discovery script, create some code that will detect whether the software is installed. For my example, I used the code below which simply checks for the existence of a registry key.


## Discovery script for Veritas Enterprise Vault Outlook Add-in (x64) 12.2.1.1485

$RegKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{0DBA46D1-5D49-4888-BC50-D3DF38F85126}"
If (Test-Path $RegKey)
{
    "Compliant"
}
Else
{
    "Not compliant"
}

It’s important that the script outputs a value whether it’s compliant or not, so you don’t get issues with the instance not being found.

For the remediation script, I created the following code to display a toast notification:

## Displays a Windows 10 Toast Notification for a ConfigMgr Application deployment
## To be used in a compliance item
## References
# Options for audio: https://docs.microsoft.com/en-us/uwp/schemas/tiles/toastschema/element-audio#attributes-and-elements
# Toast content schema: https://docs.microsoft.com/en-us/windows/uwp/design/shell/tiles-and-notifications/toast-schema
# Datetime format for deadline: Ref: https://msdn.microsoft.com/en-us/library/system.datetime(v=vs.110).aspx
# Required parameters
$Title = "Enterprise Vault Outlook Add-in"
$SoftwarecenterShortcut= "softwarecenter:SoftwareID=ScopeId_8E25450A-4C7E-4508-B501-B3F0E2C91541/Application_abd1dcbe-275a-4be1-9800-1c1e9a0ce7ff"
$AudioSource = "ms-winsoundevent:Notification.Default"
$SubtitleText = "A new version of the Enterprise Vault Outlook Add-in is now available."
$BodyText = "The add-in provides email archiving functionality. This update is necessary for compatibility with Office 365. Please close Outlook before updating."
$HeaderFormat = "ImageOnly" # Choose from "TitleOnly", "ImageOnly" or "ImageAndTitle"
# Optional parameters
# Base64 string for an image, see Images section below to create the string
$Base64Image = "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"
# Deployment deadline
#[datetime]$Deadline = "21 June 2018 15:00"
# Calculated parameters
If ($Deadline)
{
$TimeSpan = $Deadline [datetime]::Now
}
## Images
# Convert an image file to base64 string
<#
$File = "C:\Users\tjones\Pictures\ICON_EV_LOGO_Resized.png"
$Image = [System.Drawing.Image]::FromFile($File)
$MemoryStream = New-Object System.IO.MemoryStream
$Image.Save($MemoryStream, $Image.RawFormat)
[System.Byte[]]$Bytes = $MemoryStream.ToArray()
$Base64 = [System.Convert]::ToBase64String($Bytes)
$Image.Dispose()
$MemoryStream.Dispose()
$Base64 | out-file "C:\Users\tjones\Pictures\ICON_EV_LOGO_Resized.txt" # Save to text file, copy and paste from there to the $Base64Image variable
#>
# Create an image file from base64 string and save to user temp location
If ($Base64Image)
{
$ImageFile = "$env:TEMP\ToastLogo.png"
[byte[]]$Bytes = [convert]::FromBase64String($Base64Image)
[System.IO.File]::WriteAllBytes($ImageFile,$Bytes)
}
# Load some required namespaces
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
# Register the AppID in the registry for use with the Action Center, if required
$app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
$AppID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
$RegPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings'
if (!(Test-Path Path "$RegPath\$AppId")) {
$null = New-Item Path "$RegPath\$AppId" Force
$null = New-ItemProperty Path "$RegPath\$AppId" Name 'ShowInActionCenter' Value 1 PropertyType 'DWORD'
}
# Define the toast notification in XML format
[xml]$ToastTemplate = @"
<toast scenario="reminder">
<visual>
<binding template="ToastGeneric">
<text>New Software Notification</text>
<text placement="attribution">from Contoso IT</text>
<group>
<subgroup>
<text hint-style="title" hint-wrap="true" >$Title</text>
</subgroup>
</group>
<group>
<subgroup>
<text hint-style="subtitle" hint-wrap="true" >$SubtitleText</text>
</subgroup>
</group>
<group>
<subgroup>
<text hint-style="body" hint-wrap="true" >$BodyText</text>
</subgroup>
</group>
</binding>
</visual>
<actions>
<action content="Install now" activationType="protocol" arguments="$SoftwarecenterShortcut" />
<action content="Another time…" arguments="" />
</actions>
<audio src="$AudioSource"/>
</toast>
"@
# Change up the headers as required
If ($HeaderFormat -eq "TitleOnly")
{
$ToastTemplate.toast.visual.binding.group[0].subgroup.InnerXml = "<text hint-style=""title"" hint-wrap=""true"" >$Title</text>"
}
If ($HeaderFormat -eq "ImageOnly")
{
$ToastTemplate.toast.visual.binding.group[0].subgroup.InnerXml = "<image src=""$ImageFile""/>"
}
If ($HeaderFormat -eq "ImageAndTitle")
{
$ToastTemplate.toast.visual.binding.group[0].subgroup.InnerXml = "<text hint-style=""title"" hint-wrap=""true"" >$Title</text><image src=""$ImageFile""/>"
}
# Add a deadline if required
If ($Deadline)
{
$DeadlineGroups = @"
<group>
<subgroup>
<text hint-style="base" hint-align="left">Deadline</text>
<text hint-style="caption" hint-align="left">$(Get-Date Date $Deadline Format "dd MMMM yyy HH:mm")</text>
</subgroup>
<subgroup>
<text hint-style="base" hint-align="right">Time Remaining .</text>
<text hint-style="caption" hint-align="right">$($TimeSpan.Days) days $($TimeSpan.Hours) hours $($TimeSpan.Minutes) minutes .</text>
</subgroup>
</group>
"@
$ToastTemplate.toast.visual.binding.InnerXml = $ToastTemplate.toast.visual.binding.InnerXml + $DeadlineGroups
}
# Load the notification into the required format
$ToastXml = New-Object TypeName Windows.Data.Xml.Dom.XmlDocument
$ToastXml.LoadXml($ToastTemplate.OuterXml)
# Display
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show($ToastXml)

Code Walkthrough

Let’s walk through the code to explain the variables and what it does.

Variables

Title is the notification title that displays more prominently, the name of the software for example.

SoftwareCenterShortcut is the sharing link from your ConfigMgr application. To get this, you simply deploy the application to a machine, go to the Software Center, open the application and in the top-right click the link and copy and paste the link as the variable value.

AudioSource is the sound that displays when the notification appears. There are various options here, see the reference in the script for more info.

SubtitleText and BodyText contain the main wording in the notification.

HeaderFormat is a choice of either:

  1. TitleOnly – this just displays a title in the notification header
  2. ImageOnly – this just displays an image in the notification header
  3. TitleAndImage – this displays both

Base64Image – if you wish to include an image or a logo, use this optional variable. You need to convert an image file to a base64 string first, and code is included in the script for how to do that. You can output the base64 string to a text file and copy and paste it back into the script in this variable.

The reason for encoding the image is simply to avoid any dependencies on files in network locations, setting directory access or requiring internet access. The script will convert the base64 string back to an image file and save it in the user’s temporary directory.

Deadline is an optional parameter. If your deployment has a deadline, you probably want to include that in the notification. Deadline should be a parseable datetime format.

What the Script Does

The script will register PowerShell in the HKCU registry as an application that can display notifications in the Action Center, if it isn’t registered already.

Next it defines the toast notification in XML format. I chose XML to avoid any dependencies on external modules, and it’s actually quite simple to create a notification that way. The schema for toast notification is all documented by Microsoft and you can find a reference in the script.

Next it manipulates the XML a bit depending on whether you chose to display an image or use a deadline etc.

Finally, the notification is displayed.

Duration

The notification uses the reminder scenario so that it stays visible on the screen until the user takes action with it. If this is undesirable, you can change it to a normal notification with either the standard or longer duration. In this case, you need to be sure that the text in the notification can be read in that time frame.

In the toast template XML definition, change the first line from:

<toast scenario=”reminder”>

to either (default duration 5 seconds)

<toast duration=”short”>

or (around 25 seconds)

<toast duration=”long”>

Creating the Compliance Item and Baseline

When creating the compliance item in SCCM, make sure of the following:

  • Supported platforms – should be Windows 10 only. Actually, I have used some features in toast notifications that are only available in the Anniversary Update and later, so don’t target versions less than.
  • User context – make sure the compliance item has the option Run scripts by using the logged on user credentials checked
  • Compliance rule value – the value returned by the script should equal “Compliant
  • Compliance rule remediation – make sure that Run the specified remediation script when this setting is noncompliant is checked

When creating the deployment for the compliance baseline in SCCM, make sure of the following:

  • Remediate noncompliant rules when supported is checked
  • Allow remediation outside the maintenance window is checked (if that is acceptable in your environment)

Conclusion

This is a handy way to create your own notifications for ConfigMgr application deployments in Windows 10 and is fully customizable per application, within the limits of the toast notification schema. If and when Microsoft make sharing links available for task sequences, or packages and programs too, this would become even more useful, for example, sending a custom notification when a Windows 10 version upgrade is available.

PowerShell DeepDive: WPF, Data Binding and INotifyPropertyChanged

PowerShell can be used to create some nice UI front-ends using the Windows Presentation Framework (WPF). You can create anything from a simple popup window to a full-blown, self-contained application. A major concept in WPF is that of data binding. Data binding allows you to create some separation between the design of the UI and data it operates with. Binding UI element attributes to a data source means that when you need to update data in the UI, you don’t need to edit the object property itself, rather you simply update the data source it is bound to. For that to work automatically, you need to bind to a collection type that implements the INotifyPropertyChanged interface. This interface provides a notification mechanism that can notify the bound element when a value in the datasource has changed. The UI can then automatically update to reflect the change.

Without using data binding you need to create some code to update the UI element and tell it what new data to display. In a single-threaded UI you can simply set the object property directly, and in a multi-threaded UI you will invoke the dispatcher on the object, so that the property can be safely updated without contention from another thread.

Maybe this all sound Greek (no offence if you speak Greek!), so lets do a couple of examples to illustrate.

Increment Me!

Here I created a simple function that will display a WPF UI window. Then I created an event handler so that when the button is clicked, the textbox will update. The textbox contains a number that will be incremented every time you click the button.


Add-Type -AssemblyName PresentationFramework

function Create-WPFWindow {
    Param($Hash)

    # Create a window object
    $Window = New-Object System.Windows.Window
    $Window.SizeToContent = [System.Windows.SizeToContent]::WidthAndHeight
    $Window.Title = "WPF Window"
    $window.WindowStartupLocation = [System.Windows.WindowStartupLocation]::CenterScreen
    $Window.ResizeMode = [System.Windows.ResizeMode]::NoResize

    # Create a textbox object
    $TextBox = New-Object System.Windows.Controls.TextBox
    $TextBox.Height = 85
    $TextBox.HorizontalContentAlignment = "Center"
    $TextBox.VerticalContentAlignment = "Center"
    $TextBox.FontSize = 30
    $Hash.TextBox = $TextBox

    # Create a button object
    $Button = New-Object System.Windows.Controls.Button
    $Button.Height = 85
    $Button.HorizontalContentAlignment = "Center"
    $Button.VerticalContentAlignment = "Center"
    $Button.FontSize = 30
    $Button.Content = "Increment Me!"
    $Hash.Button = $Button

    # Assemble the window
    $StackPanel = New-Object System.Windows.Controls.StackPanel
    $StackPanel.Margin = "5,5,5,5"
    $StackPanel.AddChild($TextBox)
    $StackPanel.AddChild($Button)
    $Window.AddChild($StackPanel)
    $Hash.Window = $Window
}

# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Set the value of the textbox
$Hash.TextBox.Text = [int]0

# Add an event for the button click
$Hash.Button.Add_Click{

    # Get the current value
    [int]$CurrentValue = $Hash.TextBox.Text

    # Increment the value
    $CurrentValue ++

    # Update the text property with the new value
    $Hash.TextBox.Text = $CurrentValue
} 

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

gif

Since the data type for the text property of the textbox is a string, I cannot increment the value directly, I must first cast it into an integer, then return it back to the textbox where it will again be stored as a string. Since the textbox text property is not bound to a data source, I must update the property directly. In this simple example that is not a problem, but it does mean that I cannot work independently of the data.

Creating a data binding

In the following example I have bound the textbox text property to a data source. Data binding in WPF can be done either in XAML or code, but here we are using code to illustrate how that can be done.

First, we will create a datasource object. This will be what we bind to. Since the datasource must notify the UI when the bound value has changed, we must use a collection that implements the INotifyPropertyChanged interface. You can actually create a custom class in C# to do that, as Ryan Ephgrave nicely describes on his blog. This is quite a flexible method, however it does mean you need to create all the properties used in your datasource up front in the class. A ‘native’ way to do that in .Net is to use an observable collection.

In this example, I am going to use an observable collection with a simple System.Object type, so I can add multiple kinds of object to the collection if I want.


# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Create a datacontext for the textbox and set it
$DataContext = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$Text = [int]0
$DataContext.Add($Text)
$hash.TextBox.DataContext = $DataContext

After creating the observable collection, I then add my initial value for the textbox – 0, and finally set the collection as the datacontext for the WPF window.

When using data binding, one can either declare the binding source directly, or set the datacontext for one of the parent elements. When you set the datacontext at the window level for example, this will be inherited by default by any child elements in the window, such as textboxes, panels etc. For more granular control, you can set the datacontext on the object itself rather than at the top-level. In either case, the bound object will use this datacontext.

Next, I create a binding object and pass to it which object in the datasource I want to bind to. In this case, I only have one item in the collection so I can simply reference it using it’s index number, which is [0]. If the collection contains multiple items, you need to keep a reference of which index your items are using.


# Create and set a binding on the textbox object
$Binding = New-Object System.Windows.Data.Binding -ArgumentList "[0]"

Another way to do that would be to set the path property of the binding object:


# Create and set a binding on the textbox object
$Binding = New-Object System.Windows.Data.Binding
$Binding.Path = "[0]"

If you prefer not to use a datacontext, you can also declare a binding source in the binding object instead:


# Create and set a binding on the textbox object
$Binding = New-Object System.Windows.Data.Binding
$Binding.Path = "[0]"
$Binding.Source = $DataContext

Now I need to set the binding mode, which is oneway in the case:


$Binding.Mode = [System.Windows.Data.BindingMode]::OneWay

And then finally I create the binding on the WPF element property. For the overload of the SetBinding() method, I need to provide the object I am binding to ($Hash.TextBox), then the property I am binding to (Text), then the binding object itself.


[void][System.Windows.Data.BindingOperations]::SetBinding($Hash.TextBox,[System.Windows.Controls.TextBox]::TextProperty, $Binding)

Now I can handle the button click event, but this time instead of updating the “text” property directly, I simply update the datasource it is bound to. In this instance, the datasource is an integer not a string, so I can increment it directly.


# Add an event for the button click
$Hash.Button.Add_Click{
    $DataContext[0] ++
} 

And finally now I run the window and the result is the same: the number is incremented with every click of the button. Only the datasource is being updated, but the text property of the textbox is bound to it and updates automatically when notified by the observable collection.

gif3

Here is the full code (using the same WPF Window function):


# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Create a datacontext for the textbox and set it
$DataContext = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$Text = [int]0
$DataContext.Add($Text)
$hash.TextBox.DataContext = $DataContext

# Create and set a binding on the textbox object
$Binding = New-Object System.Windows.Data.Binding # -ArgumentList "[0]"
$Binding.Path = "[0]"
$Binding.Mode = [System.Windows.Data.BindingMode]::OneWay
[void][System.Windows.Data.BindingOperations]::SetBinding($Hash.TextBox,[System.Windows.Controls.TextBox]::TextProperty, $Binding)

# Add an event for the button click
$Hash.Button.Add_Click{
    $DataContext[0] ++
} 

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

Multi-threading using the Dispatcher

Using data binding in this way can simplify the way you design your UI, and can also result in better UI performance, especially when using multiple threads because calling the dispatcher from another thread has a higher overhead. Using background threads is essential whenever you run any code that takes long enough that it will cause the UI to freeze and be unresponsive. Offloading that code to a background thread allows the UI to continue running happily in the main thread.

Does data binding work across threads? Yes it does, although when dealing with additional threads you need to consider the thread safety of the collections you are using as your datasource, as not all collections are inherently thread safe. There are some ways to achieve that though, but that’s for another post. Thread safety is important when you have multiple threads that may wish to access the same shared object at the same time.

For now, to illustrate data binding with a multi-threaded UI, lets first create the same WPF window but we will create and use a background thread to update the UI directly using the dispatcher. Each time the button is clicked, a new thread is created and the UI updated from that thread. The dispatcher allows us to queue work items for the window. This is a safe way to update the UI when using multiple threads as the dispatcher will manage each request in turn ensuring we do not suffer contention.

We are using the same WPF window function as previously. When the button is clicked, we read the current value of the texbox into a variable then increment it. We spin up a background thread and call the dispatcher on the window to update the UI. I’ve also included some code to cleanup completed threads using the Get-Runspace cmdlet (this is only available since PowerShell 5)


# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Set textbox value
$Hash.TextBox.Text = "0"

# Add an event for the button click
$Hash.Button.Add_Click{

    # Cleanup any completed runspaces
    $FinishedRS = (Get-Runspace | where {$_.RunspaceAvailability -ne "Busy"})
    If ($FinishedRS)
    {
        $FinishedRS.Dispose()
    }

    # Get current textbox value and increment
    [int]$CurrentValue = $Hash.TextBox.Text
    $CurrentValue ++

    # Create and invoke a background thread
    $ScriptBlock = {
        Param($Hash,$CurrentValue)
        $Hash.Window.Dispatcher.Invoke({
            $Hash.TextBox.Text = $CurrentValue
        })
    }
     $PowerShell = [PowerShell]::Create()
    [void]$PowerShell.AddScript($ScriptBlock)
    [void]$PowerShell.AddArgument($Hash)
    [void]$PowerShell.AddArgument($CurrentValue)
    $PowerShell.BeginInvoke()
} 

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

# Cleanup runspaces
(Get-Runspace | where {$_.RunspaceAvailability -ne "Busy"}).Dispose()

The result is that it works the same as before, but the response is somewhat slower. This is due to the additional overhead of creating a background thread and calling the dispatcher.

singlethreaddispatch

Multi-threading using data binding

Now lets do the same thing and use data binding. In this case, I only need to pass the datasource object to the background thread. When I update it in the background thread, the change is also reflected in the main UI thread.


# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Create a datacontext for the textbox and set it
$DataContext = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$Text = [int]0
$DataContext.Add($Text)
$hash.TextBox.DataContext = $DataContext

# Create and set a binding on the textbox object
$Binding = New-Object System.Windows.Data.Binding -ArgumentList "[0]"
$Binding.Mode = [System.Windows.Data.BindingMode]::OneWay
[void][System.Windows.Data.BindingOperations]::SetBinding($Hash.TextBox,[System.Windows.Controls.TextBox]::TextProperty, $Binding)

# Add an event for the button click
$Hash.Button.Add_Click{

    # Cleanup any completed runspaces
    $FinishedRS = (Get-Runspace | where {$_.RunspaceAvailability -ne "Busy"})
    If ($FinishedRS)
    {
        $FinishedRS.Dispose()
    }

    # Create and invoke a background thread
    $ScriptBlock = {
        Param($DataContext)
        $DataContext[0] ++
    }
     $PowerShell = [PowerShell]::Create()
    [void]$PowerShell.AddScript($ScriptBlock)
    [void]$PowerShell.AddArgument($DataContext)
    $PowerShell.BeginInvoke()
} 

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

# Cleanup runspaces
(Get-Runspace | where {$_.RunspaceAvailability -ne "Busy"}).Dispose()

The result this time is that even though there remains a small overhead in creating and managing a background thread, we get a much quicker response. Score!

singlethreaddb

Binding to the UI

Data binding can also be used to bind to items in the UI itself. In the following example, we have two textboxes. The one on the left is bound to a datasource that is incremented everytime the button is clicked. The one on the right is bound to the text property of the textbox on the left. The result is that both textboxes update incrementally, even though only one is bound to a datasource.

uibinding

This time, in the code, I have created a simple function to create a binding. When binding to a UI element, it is necessary to set both the source and path properties of the binding.


function Create-WPFWindow {
    Param($Hash)

    # Create a window object
    $Window = New-Object System.Windows.Window
    $Window.SizeToContent = [System.Windows.SizeToContent]::WidthAndHeight
    $Window.Title = "WPF Window"
    $window.WindowStartupLocation = [System.Windows.WindowStartupLocation]::CenterScreen
    $Window.ResizeMode = [System.Windows.ResizeMode]::NoResize

    # Create a button object
    $Button = New-Object System.Windows.Controls.Button
    $Button.Height = 85
    $Button.Width = [System.Double]::NaN # "auto" in XAML
    $Button.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center
    $Button.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center
    $Button.FontSize = 30
    $Button.Content = "Increment Me!"
    $Hash.Button = $Button

    # Create a textbox object
    $TextBox1 = New-Object System.Windows.Controls.TextBox
    $TextBox1.Name = "FirstTextBox"
    $TextBox1.Height = 85
    $TextBox1.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center
    $TextBox1.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center
    $TextBox1.FontSize = 30
    $TextBox1.BorderThickness = 0
    $Hash.TextBox1 = $TextBox1

    # Create a textbox object
    $TextBox2 = New-Object System.Windows.Controls.TextBox
    $TextBox2.Height = 85
    $TextBox2.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center
    $TextBox2.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center
    $TextBox2.FontSize = 30
    $TextBox2.BorderThickness = 0
    $Hash.TextBox2 = $TextBox2

    # Assemble the window
    $StackPanel = New-Object System.Windows.Controls.StackPanel
    $StackPanel.Orientation = [System.Windows.Controls.Orientation]::Horizontal
    $StackPanel.AddChild($TextBox1)
    $StackPanel.AddChild($TextBox2)

    $MainStackPanel = New-Object System.Windows.Controls.StackPanel
    $MainStackPanel.Margin = "5,5,5,5"
    $MainStackPanel.AddChild($StackPanel)
    $MainStackPanel.AddChild($Button)
    $Window.AddChild($MainStackPanel)
    $Hash.Window = $Window
}

Function Set-Binding {
    Param($Target,$Property,$Path,$Source)

    $Binding = New-Object System.Windows.Data.Binding
    $Binding.Path = $Path
    $Binding.Mode = [System.Windows.Data.BindingMode]::OneWay
    If ($Source)
    {
        $Binding.Source = $Source
    }
    [void][System.Windows.Data.BindingOperations]::SetBinding($Target,$Property,$Binding)

    # Another way to do it...
    #[void]$Target.SetBinding($Property,$Binding)
}

# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Create a datasource and set the initial value
$DataSource = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$DataSource.Add([int]0)
$DataSource.Add([int]0)
$Hash.Window.DataContext = $DataSource

# Bind the first text box to the data source
Set-Binding -Target $Hash.TextBox1 -Property $([System.Windows.Controls.TextBox]::TextProperty) -Path "[0]"

# Bind the second text box to the first textbox, text property
Set-Binding -Target $Hash.TextBox2 -Property $([System.Windows.Controls.TextBox]::TextProperty) -Source $Hash.TextBox1 -Path $([System.Windows.Controls.TextBox]::TextProperty)

# Event: Window Loaded
$Hash.Window.Add_Loaded{

    # Set the textbox widths to half the size of the button
    $Hash.TextBox1, $Hash.TextBox2 | foreach {
        $_.Width = $Hash.Button.ActualWidth / 2
    }

}

# Event: Button Clicked
$Hash.Button.Add_Click{

    # Increment the data source value
    $DataSource[0] ++

}

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

Learn your times tables!

Finally, my last example is a simple tool to help you learn your multiplication tables (you know you need reminding 😉 ). Here, the 3 text boxes with numbers are all bound to a datasource. When a button is clicked, the value is incremented or decremented in the datasource, and this is reflected in the UI by the binding. The result is then calculated based on the new values, and this is done by handling the TextChanged event on the first 2 text boxes. Again, the UI is never being updated directly, only the datasource is being changed, but the UI also updates because of the bindings.

multipier

The code for this is a bit long because I am creating the entire WPF window the ‘old-school’ way – in code, rather than in XAML. In practice, it is much better to create the UI definition in XAML, and then you will use a combination of XAML and PowerShell code to manage the UI.


function Create-WPFWindow {
    Param($Hash)

    # Create a window object
    $Window = New-Object System.Windows.Window
    $Window.SizeToContent = [System.Windows.SizeToContent]::WidthAndHeight
    $Window.Title = "Multiplication Tables"
    $window.WindowStartupLocation = [System.Windows.WindowStartupLocation]::CenterScreen
    $Window.ResizeMode = [System.Windows.ResizeMode]::NoResize

    # Create the first value textbox
    $TextBoxProperties = @{Height = 85; Width = 60; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center; FontSize = 30; BorderThickness = 0; IsReadOnly = $True}
    $TextBox1 = New-Object System.Windows.Controls.TextBox
    $TextBoxProperties.GetEnumerator() | foreach {
        $TextBox1.$($_.Name) = $_.Value
    }
    $Hash.TextBox1 = $TextBox1

    # Create the "x" textbox
    $TextBoxProperties = @{Height = 85; Width = 40; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center; FontSize = 30; BorderThickness = 0; Text = "x"; IsReadOnly = $True}
    $TextBox2 = New-Object System.Windows.Controls.TextBox
    $TextBoxProperties.GetEnumerator() | foreach {
        $TextBox2.$($_.Name) = $_.Value
    }
    $Hash.TextBox2 = $TextBox2

     # Create the second value textbox
    $TextBoxProperties = @{Height = 85; Width = 60; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center; FontSize = 30; BorderThickness = 0; IsReadOnly = $True }
    $TextBox3 = New-Object System.Windows.Controls.TextBox
    $TextBoxProperties.GetEnumerator() | foreach {
        $TextBox3.$($_.Name) = $_.Value
    }
    $Hash.TextBox3 = $TextBox3

    # Create the "=" textbox
    $TextBoxProperties = @{Height = 85; Width = 40; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center; FontSize = 30; BorderThickness = 0; Text = "="; IsReadOnly = $True }
    $TextBox4 = New-Object System.Windows.Controls.TextBox
    $TextBoxProperties.GetEnumerator() | foreach {
        $TextBox4.$($_.Name) = $_.Value
    }
    $Hash.TextBox4 = $TextBox4

    # Create the calculated value textbox
    $TextBoxProperties = @{Height = 85; Width = 80; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Center; FontSize = 30; BorderThickness = 0; IsReadOnly = $True}
    $TextBox5 = New-Object System.Windows.Controls.TextBox
    $TextBoxProperties.GetEnumerator() | foreach {
        $TextBox5.$($_.Name) = $_.Value
    }
    $Hash.TextBox5 = $TextBox5

    # Create the first "+" button
    $ButtonProperties = @{Height = 30; Width = 30; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Top; FontSize = 20; Content = "+"; Margin = "5,0,0,0"}
    $Button1 = New-Object System.Windows.Controls.Button
    $ButtonProperties.GetEnumerator() | foreach {
        $Button1.$($_.Name) = $_.Value
    }
    $Hash.Button1 = $Button1

    # Create the first "-" button
    $ButtonProperties = @{Height = 30; Width = 30; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Top; FontSize = 20; Content = "-"; Margin = "5,0,0,0"}
    $Button2 = New-Object System.Windows.Controls.Button
    $ButtonProperties.GetEnumerator() | foreach {
        $Button2.$($_.Name) = $_.Value
    }
    $Hash.Button2 = $Button2

    # Create the second "+" button
    $ButtonProperties = @{Height = 30; Width = 30; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Top; FontSize = 20; Content = "+"; Margin = "28,0,0,0"}
    $Button3 = New-Object System.Windows.Controls.Button
    $ButtonProperties.GetEnumerator() | foreach {
        $Button3.$($_.Name) = $_.Value
    }
    $Hash.Button3 = $Button3

    # Create the second "-" button
    $ButtonProperties = @{Height = 30; Width = 30; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Top; FontSize = 20; Content = "-"; Margin = "5,0,0,0"}
    $Button4 = New-Object System.Windows.Controls.Button
    $ButtonProperties.GetEnumerator() | foreach {
        $Button4.$($_.Name) = $_.Value
    }
    $Hash.Button4 = $Button4

     # Create the reset button
    $ButtonProperties = @{Height = 30; Width = 60; HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center; VerticalContentAlignment = [System.Windows.VerticalAlignment]::Top; FontSize = 20; Content = "Reset"; Margin = "40,0,0,0"}
    $Button5 = New-Object System.Windows.Controls.Button
    $ButtonProperties.GetEnumerator() | foreach {
        $Button5.$($_.Name) = $_.Value
    }
    $Hash.Button5 = $Button5

    # Assemble the first stackpanel
    $StackPanel = New-Object System.Windows.Controls.StackPanel
    $StackPanel.Orientation = [System.Windows.Controls.Orientation]::Horizontal
    $TextBox1, $TextBox2, $TextBox3, $TextBox4, $TextBox5 | foreach {
        $StackPanel.AddChild($_)
    }

    # Assemble the second stackpanel
    $StackPanel2 = New-Object System.Windows.Controls.StackPanel
    $StackPanel2.Orientation = [System.Windows.Controls.Orientation]::Horizontal
    $Button1, $Button2, $Button3, $Button4, $Button5 | foreach {
        $StackPanel2.AddChild($_)
    }

    # Assemble the window
    $MainStackPanel = New-Object System.Windows.Controls.StackPanel
    $MainStackPanel.Margin = "5,5,5,5"
    $MainStackPanel.AddChild($StackPanel)
    $MainStackPanel.AddChild($StackPanel2)
    $Window.AddChild($MainStackPanel)
    $Hash.Window = $Window
}

Function Set-Binding {
    Param($Target,$Property,$Path,$Source)

    $Binding = New-Object System.Windows.Data.Binding
    $Binding.Path = $Path
    $Binding.Mode = [System.Windows.Data.BindingMode]::OneWay
    If ($Source)
    {
        $Binding.Source = $Source
    }
    [void][System.Windows.Data.BindingOperations]::SetBinding($Target,$Property,$Binding)

    # Another way to do it...
    #[void]$Target.SetBinding($Property,$Binding)
}

# Create a WPF window and add it to a Hash table
$Hash = @{}
Create-WPFWindow $Hash

# Create a datasource and set the initial values
$DataSource = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$DataSource.Add([int]1)
$DataSource.Add([int]1)
$DataSource.Add([int]1)
$Hash.Window.DataContext = $DataSource

# Bind the value text boxes to the data source
Set-Binding -Target $Hash.TextBox1 -Property $([System.Windows.Controls.TextBox]::TextProperty) -Path "[0]"
Set-Binding -Target $Hash.TextBox3 -Property $([System.Windows.Controls.TextBox]::TextProperty) -Path "[1]"
Set-Binding -Target $Hash.TextBox5 -Property $([System.Windows.Controls.TextBox]::TextProperty) -Path "[2]"

# Events: Button Clicks
$Hash.Button1.Add_Click{
    # Increment
    $DataSource[0] ++
}

$Hash.Button2.Add_Click{
    # Decrement
    $DataSource[0] --
}

$Hash.Button3.Add_Click{
    # Increment
    $DataSource[1] ++
}

$Hash.Button4.Add_Click{
    # Decrement
    $DataSource[1] --
}

$Hash.Button5.Add_Click{
    # Reset Values to 1
    $DataSource[0] = [int]1
    $DataSource[1] = [int]1
}

# Events: TextBox values changed
$Hash.TextBox1, $Hash.TextBox3, $hash.TextBox5 | foreach {
    $_.Add_TextChanged{
        # Calculate
        $DataSource[2] = $DataSource[0] * $DataSource[1]
    }
}

# Show the window
[void]$Hash.Window.Dispatcher.InvokeAsync{$Hash.Window.ShowDialog()}.Wait()

Conclusion

Data binding can get much more complex than this including two-way bindings, priority binding and multiple bindings, but hopefully these examples will whet your appetite to explore it further. When using data binding in your UI you are taking a step towards implementing the MVVM, a kind of best-practice concept for designing UI applications. Whether you really need to use data binding or not depends a lot on the type of UI you are creating and what it does, but certainly consider taking advantage of it to create a more efficient and logically designed PowerShell UI.

Have fun!

Explore WPF Controls with PowerShell

If you’ve ever tried creating a tool or an application with WPF, you know that the built-in controls contain many properties, methods and events, and finding the right one to use can be, well, fun!

As an aid to creating WPF applications, I created this simple tool which explores the various controls and exposes their properties, methods and events. It’s handy as a quick reference, or as a convenient way to get familiar with the various controls.

The tool is a PowerShell script, so simply download the script, right-click and ‘Run with PowerShell’.

More info here: https://smsagent.wordpress.com/tools/wpf-control-explorer/

wpf-controlexplorer

New Free App – ConfigMgr Deployment Reporter

Just released a new free application for ConfigMgr admins – ConfigMgr Deployment Reporter.  I developed this app for use in the organisation I currently work for, and it turned out quite well, so I decided to release a public version to the community!

capture

I developed this app as an alternative (and IMO easier) way to report on ConfigMgr deployments than using the ConfigMgr console. It uses a little different format than the console node allowing you to select which deployment you wish to view data for based on the “feature type” (ie application, package etc) and report on only that deployment.  It also introduces a separation of results between all applicable systems for a deployment, and only those systems which have currently reported status, which allows for a more accurate view of the success of a deployment as it progresses.

The app allows the creation of charts and HTML-format reports to give a nice graphical snapshot of a deployment.

I also added the capability to report per-device for Software Update and Task Sequence deployments.  For Software Updates, this allows you to see which updates from the deployment are applicable to the machine and the status of each update, and for Task Sequences it allows viewing the execution status of each step in the task sequence for the selected device.

As usual, I code purely in PowerShell using WPF for the UI.  This time I added metro styling using the excellent MahApps.Metro project 🙂

Download the app from here.

Detect an Active VPN Adapter During ConfigMgr Deployments

A common requirement with ConfigMgr deployments is to exclude clients that are connected to the corporate network via a VPN, when the total size of the content files for the deployment are too much to be throwing down a slow network link. There is more than one way to do this, but I have seen that not all are reliable and do not work in every case or for every VPN adapter out there.

For example, using PowerShell, you can run either of the following WMI queries to potentially detect an active VPN adapter (your VPN adapter description may be different):

Using Win32_NetworkAdapter


Get-WmiObject -Query "Select * from Win32_NetworkAdapter where Name like '%VPN%' and NetEnabled='True'"

Using Win32_NetworkAdapterConfiguration


Get-WmiObject -Query "Select * from Win32_NetworkAdapterConfiguration where Description like '%VPN%' and IPEnabled='True'"

Since Windows 8 / Server 2012 you can also use the Get-VPNConnection cmdlet:


(Get-VpnConnection -AllUserConnection).where{$_.Name -like "*VPN*" -and $_.ConnectionStatus -eq "Connected"}

Another method is simply:


ipconfig | Select-String 'PPP adapter'

But my preferred method is to check the IPv4 routing table. This is because VPN connections typically use their own subnet, so when connected they will add entries to the IP routing table for that subnet, and will remove them again when disconnected. If you know the subnets used by your VPN connections, you can query for them in WMI:


Get-WmiObject -Query "Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%' 

To use this with Application deployments in ConfigMgr, you can create a Global Condition with a script setting.  This condition could be used either to target or to exclude systems using VPN:

capture

Here is an example script that returns “VPN-Active” or “VPN-InActive” based on whether a VPN subnet is detected:


If (Get-WmiObject -Query "Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%'")
    {Write-host "VPN-Active"}
Else {Write-host "VPN-InActive"}

You can then add this as a requirement to an application:

capture

For task sequences, you can use a WMI query condition:

WMI Query


Select * from Win32_IP4RouteTable where Name like '10.0.99.%' or Name like '10.15.99.%'

 

capture

The only concession is if your VPN subnets ever change, you will need to update them in ConfigMgr.

New Tool: System Explorer for Windows

Today I am pleased to release a new tool for enterprises and home users alike: System Explorer for Windows.  This application can be used to view detailed system and hardware data for a local or remote computer by exposing WMI Win32 classes in an easy-to-use Graphical User Interface.  For enterprises that use System Center Configuration Manager, the application can connect to the SCCM database to allow viewing of hardware inventory data even if the target system is not currently online.

capture

System Explorer for Windows – Client WMI view

Check it out here: https://smsagent.wordpress.com/tools/system-explorer-for-windows/

 

Creating WPF GUI Applications with Pure PowerShell

When creating a GUI application in PowerShell, I usually use Visual Studio, or Blend for Visual Studio, to design a WPF application, then copy and run the XAML code in PowerShell.  Designing in VS is generally easier and quicker and creates less code, but it is also perfectly possible to create a WPF GUI using pure PowerShell, which is more akin to the Windows Forms method of GUI creation.  For more complex applications that’s not the ideal way because it takes longer and creates a lot more code, but for simple applications, or if you are used to designing something in Windows Forms, why not give it a go?  You simply need to create a window from the [System.Windows] .Net namespace, then add some controls from the [System.Windows.Controls] namespace, and you’re away!

Here’s a simple example application that displays the running processes on your machine, the list of services, and a little game “Click the Fruit” as a bonus 😉

capture


# Add required assembly
Add-Type -AssemblyName PresentationFramework

# Create a Window
$Window = New-Object Windows.Window
$Window.Height = "670"
$Window.Width = "700"
$Window.Title = "PowerShell WPF Window"
$window.WindowStartupLocation="CenterScreen"

# Create a grid container with 2 rows, one for the buttons, one for the datagrid
$Grid =  New-Object Windows.Controls.Grid
$Row1 = New-Object Windows.Controls.RowDefinition
$Row2 = New-Object Windows.Controls.RowDefinition
$Row1.Height = "70"
$Row2.Height = "100*"
$grid.RowDefinitions.Add($Row1)
$grid.RowDefinitions.Add($Row2)

# Create a button to get running Processes
$Button_Processes = New-Object Windows.Controls.Button
$Button_Processes.SetValue([Windows.Controls.Grid]::RowProperty,0)
$Button_Processes.Height = "50"
$Button_Processes.Width = "150"
$Button_Processes.Margin = "10,10,10,10"
$Button_Processes.HorizontalAlignment = "Left"
$Button_Processes.VerticalAlignment = "Top"
$Button_Processes.Content = "Get Processes"
$Button_Processes.Background = "Aquamarine"

# Create a button to get services
$Button_Services = New-Object Windows.Controls.Button
$Button_Services.SetValue([Windows.Controls.Grid]::RowProperty,0)
$Button_Services.Height = "50"
$Button_Services.Width = "150"
$Button_Services.Margin = "180,10,10,10"
$Button_Services.HorizontalAlignment = "Left"
$Button_Services.VerticalAlignment = "Top"
$Button_Services.Content = "Get Services"
$Button_Services.Background = "Aquamarine"

# Create a button to play Click the fruit
$Button_Cool = New-Object Windows.Controls.Button
$Button_Cool.SetValue([Windows.Controls.Grid]::RowProperty,0)
$Button_Cool.Height = "50"
$Button_Cool.Width = "150"
$Button_Cool.Margin = "350,10,10,10"
$Button_Cool.HorizontalAlignment = "Left"
$Button_Cool.VerticalAlignment = "Top"
$Button_Cool.Content = "Play 'Click the Fruit'"
$Button_Cool.Background = "Aquamarine"

# Create a datagrid
$DataGrid = New-Object Windows.Controls.DataGrid
$DataGrid.SetValue([Windows.Controls.Grid]::RowProperty,1)
$DataGrid.MinHeight = "100"
$DataGrid.MinWidth = "100"
$DataGrid.Margin = "10,0,10,10"
$DataGrid.HorizontalAlignment = "Stretch"
$DataGrid.VerticalAlignment = "Stretch"
$DataGrid.VerticalScrollBarVisibility = "Auto"
$DataGrid.GridLinesVisibility = "none"
$DataGrid.IsReadOnly = $true

# Add the elements to the relevant parent control
$Grid.AddChild($DataGrid)
$grid.AddChild($Button_Processes)
$grid.AddChild($Button_Services)
$grid.AddChild($Button_Cool)
$window.Content = $Grid

# Add an event on the Get Processes button
$Button_Processes.Add_Click({
    $Processes = Get-Process | Select ProcessName,HandleCount,NonpagedSystemMemorySize,PrivateMemorySize,WorkingSet,UserProcessorTime,Id
    $DataGrid.ItemsSource = $Processes
    })

# Add an event on the Get Services button
$Button_Services.Add_Click({
    $Services = Get-Service | Select Name,ServiceName,Status,StartType
    $DataGrid.ItemsSource = $Services
    })

# Add an event to play Click the fruit
$Button_Cool.Add_Click({

    $Fruit = @{
        Apples = "Green"
        Bananas = "Yellow"
        Oranges = "Orange"
        Plums = "Maroon"
    }

    $Fruit.GetEnumerator() | Foreach {
        # Create a transparent window at a random location on the screen
        $NewWindow = New-Object Windows.Window
        $NewWindow.SizeToContent = "WidthAndHeight"
        $NewWindow.AllowsTransparency = $true
        $NewWindow.WindowStyle = "none"
        $NewWindow.Background = "Transparent"
        $NewWindow.WindowStartupLocation = "Manual"
        $Height = Get-Random -Maximum (([System.Windows.SystemParameters]::PrimaryScreenHeight) - 100)
        $Width = Get-Random -Maximum (([System.Windows.SystemParameters]::PrimaryScreenWidth) - 100)
        $NewWindow.Left = $Width
        $NewWindow.Top = $Height

        # Add a label control for the fruit
        $NewLabel = New-Object Windows.Controls.Label
        $NewLabel.Height = "150"
        $NewLabel.Width = "400"
        $NewLabel.Content = $_.Name
        $NewLabel.FontSize = "100"
        $NewLabel.FontWeight = "Bold"
        $NewLabel.Foreground = $_.Value
        $NewLabel.Background = "Transparent"
        $NewLabel.Opacity = "100"

        # Add an event to close the window when clicked
        $NewWindow.Add_MouseDown({
            $This.Close()
        })

        # Add an event to change the cursor to a hand when the mouse goes over the window
        $NewWindow.Add_MouseEnter({
        $this.Cursor = "Hand"
        })

        # Display the window
        $NewWindow.Content = $NewLabel
        $NewWindow.ShowDialog()
    }
})

# Show the window
if (!$psISE)
{
    # Hide PS console window
    $windowcode = '[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);'
    $asyncwindow = Add-Type -MemberDefinition $windowcode -name Win32ShowWindowAsync -namespace Win32Functions -PassThru
    $null = $asyncwindow::ShowWindowAsync((Get-Process -PID $pid).MainWindowHandle, 0) 

    # Run as an application in it's own context
    $app = [Windows.Application]::new()
    $app.Run($Window)
}
Else
{
    [void]$window.ShowDialog()
}

Temporarily Increasing the ConfigMgr Client Cache Size for a Large Application

Recently I had to deploy an application whose content files were larger than the default SCCM client cache size (5120 MB).  This will return an error in the Software Center, such as:

0x87D01201 (The content download cannot be performed because there is not enough available space in cache or the disk is full.)

I didn’t want to permanently increase the cache size, or require that user do it manually, so I investigated some options and came up with a couple of simple PowerShell scripts that can increase or decrease the cache size.  I put these scripts into a standard package and created a program for each script using a command-line like:

powershell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File .\Increase-CCMCacheSize

You then have various options for how you can run that.  For example, my application was being deployed using a task sequence as there are multiple steps, so I simply right-click the task sequence and on the Advanced tab, I check the option to Run another program first:

capture

This will increase the cache size before the task sequence starts to run, which means it will no longer give an error.

To restore the cache to it’s default size after the application install, I simply add an additional step in the task sequence at the end using the package I created:

capture

I haven’t tested it, but you could do something similar with the standard package model by right-clicking the package program, and setting the Run another program first option. The only issue there is that there is no option to run the script to restore the cache size after, unless you create a kind of dependency chain, ie:

Restore Cache size > (depends on) My Package > (depends on) Increase Cache size

For applications, you could also use the capability for a dependecy chain, but you would need to create the script as an application and use a detection method.

Increase-CCMCacheSize


# Increase SCCM Client cache size to 20000 MB (20GB)
$CCM = New-Object -com UIResource.UIResourceMGR
$CC = $CCM.GetCacheInfo()
$CacheSize = $CC.TotalSize

if ($CacheSize -lt 20000)
    {
        write-host "Setting cache size to 20000"
        try
        {
            $CC.TotalSize = 20000
        }
        Catch
        {
            $_
        }
}

Restore-CCMCacheSize


# Restore SCCM Client cache size to default (5120 MB)
$CCM = New-Object -com UIResource.UIResourceMGR
$CC = $CCM.GetCacheInfo()
$CacheSize = $CC.TotalSize

if ($CacheSize -gt 5120)
    {
        write-host "Setting cache size to 5120"
        try
        {
            $CC.TotalSize = 5120
        }
        Catch
        {
            $_
        }
}

Detection Method

For an application detection method, you could also use a PowerShell script, something like this:


# Detection method to check the SCCM Client cache size
$CCM = New-Object -com UIResource.UIResourceMGR
$CC = $CCm.GetCacheInfo()
$CacheSize = $CC.TotalSize

if ($CacheSize -eq 20000)
{
    write-host "Compliant"
}
Else
{
    write-host "Not-Compliant"
}