I realized it had been a while since I wrote a Friday Fun post. These posts are intended to demonstrate PowerShell in a fun and often non-practical way. The end result is generally irrelevant. The PowerShell scripting techniques and concepts I use are the real takeaways. The task is nothing more than a means to an end.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Today's project is inspired by Linux. Specifically, the WSL Ubuntu installation I run in Windows Terminal. When I first launch it, I get a welcome screen like this.
I thought, why not do something similar for PowerShell?
Text Not Objects
The first decision I made was to output formatted text. Normally, I'm always telling people "Think objects, not text." But this task, because I'm re-creating a Linux experience is all about text. I suppose I could have created a custom object and then created a custom format file, but using a here-string would be much easier. A here-string is a nice way to create a multi-line string object.
$h = @"
Welcome to PowerShell
---------------------
It is now $(Get-Date -format t)
"@
You can format the text inside the @""@ including blank lines and indenting. Because I'm using double-quotes, I can also take advantage of variable expansion. By the way, the closing "@ must be left-justified.
My PowerShell script will create a here-string, using variable expansion. For example, the first line in the welcome display shows operating system information. I opted for PowerShell information. Since I wanted to distinguish between Windows PowerShell and PowerShell so I came up with code like this.
if ($PSEdition -eq 'Desktop') {
$psname = "Windows PowerShell"
$psosbuild = $PSVersionTable.BuildVersion
}
else {
$psname = "PowerShell"
$psosbuild = $PSVersionTable.os
}
The opening of my here-string looks like:
$out = @"
Welcome to $psname $($PSVersionTable.PSVersion) [$psosbuild]
* Documentation: https://docs.microsoft.com/powershell/
* Management: https://powershellgallery.com
* Support: https://powershell.org
These are the links I chose. A nice bonus in Windows Terminal is that the links are clickable.
Formatting Dates and TimeZones
Next, I needed to recreate the DateTime string. Formatting the current date and time can be done with the -Format parameter.
Get-Date -Format "ddd MMM dd hh:mm:ss"
The format string is case-sensitive. I didn't include the year (which would be yyyy) because I want to insert the time zone. It is simple enough to run Get-Timezone to get the current value. However, in the US I also need to test if I am under DaylightSavingTime rules.
$tz = Get-Timezone #[System.TimeZone]::CurrentTimeZone
if ($tz.IsDaylightSavingTime((Get-Date))) {
$tzNameString = $tz.DaylightName
}
else {
$tzNameString = $tz.StandardName
}
Now for the tricky part. I'd really like to use a timezone abbreviation like EDT. However, the .NET Framework lacks a defined way to get this information. There are third-party solutions I could download, or probably even web-based tools I could use. Instead, I decided to keep it simple and create my own abbreviation from the time zone name.
$tzName = ($tznamestring.split() | ForEach-Object {$_[0]}) -join ""
I am splitting the string, "Eastern Daylight Time" into an array. Each word in the array is itself an array of characters. So for each item in the array, I am selecting the first letter and then finally joining them back into a string.
This approach should work fine for US users. I can't guarantee compatibility everywhere. If you want a time zone abbreviation, you may need to come up with your own code or omit it altogether.
Getting System Information
Next up is a display of system information. You can get most of this information in several ways. You can use native cmdlets like Get-Volume. You can query WMI with Get-Ciminstance. You can use Get-Counter to retrieve performance counters. Or maybe there is a command-line tool you can run and parse. The only thing that mattered to me was that I wanted it to be fast.
For example, when using Get-Cimstance you can improve performance by only getting the properties you intend to use. I decided to get memory information from Win32_Operatingsystem. But I only needed two properties.
Get-CimInstance -ClassName win32_operatingsystem -Property TotalVisibleMemorySize, FreePhysicalMemory
This is another way to do early filtering. In this situation, the actual performance gain is minimal. Getting the complete WMI class takes me 98ms compared to 73ms when limiting properties. But I'll take what I can get and this is still a good practice to follow.
$os = Get-CimInstance -ClassName win32_operatingsystem -Property TotalVisibleMemorySize, FreePhysicalMemory
$memUsed = $os.TotalVisibleMemorySize - $os.FreePhysicalMemory
$memUsage = "{0:p}" -f ($memUsed / $os.TotalVisibleMemorySize)
This is representative of the technique I used. The last line is defining the variable which will be displayed in the result. The -F operator is the .NET replacement operator. You should read about_operators to learn more. In short, the left side of the operator had numbered placeholders like {0} and {1}. The right side of the operator is a comma-separated list of values that get plugged into the corresponding positions. But, you can also quantify the format. In this example, {0:p} will format the value as a percent like 49.11%.
Formatting the Output
In the Ubuntu message, the system information is displayed in two aligned columns. In PowerShell, this is very tedious to duplicate so I opted for a single column. However, I still wanted the values to be aligned so I still had to resort to some string hackery. I needed to insert enough blank space after the entry "header", like 'System load:' so that all of the values lined up. This meant I had to account for the longest header minus the length of the current header. I ended up writing a helper function.
#This will be the longest string I have to accomodate
$longest = "IPV4 address for $($if.InterfaceAlias)".length
function _display {
param([object]$value,[int]$headlength,[int]$max =$longest)
$len = ($max - $headlength)+2
"{0}{1}" -f (' '*$len),$value
}
In my here-string, this is how I use the function.
System load:$(_display -value $sysPerf.ProcessorQueueLength -headlength 11)
Processes:$(_display -value $sysPerf.Processes -headlength 9 )
The -headlength value is inserted manually. All that remains is to display the message by running the script.
These sessions are running in Windows Terminal under different themes. But that's pretty close to the Ubuntu original!
Controlling the Display
The Ubuntu original has a mechanism so that the message only displays once a day. My PowerShell script can be run whenever you want. Still, I thought it would be fun to recreate the logic in PowerShell.
The script defines a temporary tracking file.
$trackPath = Join-Path -path $env:TEMP -ChildPath pswelcome.tmp
When the message is displayed it is also written to this file.
$out | Tee-Object -FilePath $trackPath
At the beginning of the script, I have this code, which is currently commented out.
$AlreadyRun = $False
if (Test-Path -path $trackPath) {
$f = Get-Item -path $trackPath
$ts = New-TimeSpan -Start $f.CreationTime -End (Get-Date)
if ($ts.TotalHours -le 24) {
$AlreadyRun = $True
}
}
If the tracking file exists and the creation time is less than 24 hours, set $AlreadyRun to True. I could have written this logic in a more concise manner, but it would have lacked the clarity of this code.
I use the $AlreadyRun value to test if I should run the rest of the script. This is also where I recreate the hushfile logic. The script defines that path.
$hushpath = Join-Path -Path $home -ChildPath ".hushlogin"
Now I can test for that file or if the file has already been run.
if ((Test-Path -Path $hushpath) -OR $AlreadyRun) {
#skip running the welcome code
}
else {
#run the code to create and display the message
...
The hushfile doesn't have to have any content. All PowerShell is doing is testing if it exists.
The Script
Want to try for yourself?
#requires -version 5.1
#requires -module Storage,CimCmdlets
#the hush file to disable running this script. The file
#doesn't have to have any content. It simply needs to exist.
$hushpath = Join-Path -Path $home -ChildPath ".hushlogin"
# define a temporary tracking file.
$trackPath = Join-Path -path $env:TEMP -ChildPath pswelcome.tmp
<#
Uncomment this code if you want to use a tracking file
#If the file is less than 24 hours old then skip running this script
$AlreadyRun = $False
if (Test-Path -path $trackPath) {
$f = Get-Item -path $trackPath
$ts = New-TimeSpan -Start $f.CreationTime -End (Get-Date)
if ($ts.TotalHours -le 24) {
$AlreadyRun = $True
}
}
#>
if ((Test-Path -Path $hushpath) -OR $AlreadyRun) {
#skip running the welcome code
}
else {
if ($PSEdition -eq 'Desktop') {
$psname = "Windows PowerShell"
$psosbuild = $PSVersionTable.BuildVersion
}
else {
$psname = "PowerShell"
$psosbuild = $PSVersionTable.os
}
#Wed Oct 13 08:13:45 EDT 2021
$welcomeDate = Get-Date -Format "ddd MMM dd hh:mm:ss"
#get the timezone
$tz = Get-Timezone #[System.TimeZone]::CurrentTimeZone
if ($tz.IsDaylightSavingTime((Get-Date))) {
$tzNameString = $tz.DaylightName
}
else {
$tzNameString = $tz.StandardName
}
#my hack at creating a time zone abbreviation since there is no built-in
#way that I can find to get this information. This may not work properly
#for non-US timezones
$tzName = ($tznamestring.split() | ForEach-Object {$_[0]}) -join ""
#Get Drive C usage
$c = Get-Volume -DriveLetter C
$used = $c.size - $c.SizeRemaining
$cusage = "{0:p2} of {1:n0}GB" -f ($used / $c.size), ($c.size / 1GB)
#get network adapter and IP
#filter out Hyper-V adapters and the Loopback
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.addressState -eq 'preferred' -AND $_.InterfaceAlias -notmatch "vEthernet|Loopback" } -outvariable if).IPAddress
#only get the properties I need to use for memory information
$os = Get-CimInstance -ClassName win32_operatingsystem -Property TotalVisibleMemorySize, FreePhysicalMemory
$memUsed = $os.TotalVisibleMemorySize - $os.FreePhysicalMemory
$memUsage = "{0:p}" -f ($memUsed / $os.TotalVisibleMemorySize)
#get system performance counters
$sysPerf = Get-CimInstance -ClassName Win32_PerfFormattedData_PerfOS_System -Property Processes, ProcessorQueueLength
#get pagefile information
$pagefile = Get-CimInstance -ClassName Win32_PageFileUsage -Property CurrentUsage,AllocatedBaseSize
$swap = "{0:p}" -f ($pagefile.CurrentUsage/$pagefile.AllocatedBaseSize)
<#
A helper function to format the display so that everything aligns properly.
The HeadLength is the length of the 'header' like 'System load'
#>
#This will be the longest string I have to accomodate
$longest = "IPV4 address for $($if.InterfaceAlias)".length
function _display {
param([object]$value,[int]$headlength,[int]$max =$longest)
$len = ($max - $headlength)+2
"{0}{1}" -f (' '*$len),$value
}
#build the display here-string inserting the calculated variables
$out = @"
Welcome to $psname $($PSVersionTable.PSVersion) [$psosbuild]
* Documentation: https://docs.microsoft.com/powershell/
* Management: https://powershellgallery.com
* Support: https://powershell.org
System Information as of $welcomeDate $tzName $((Get-Date).year)
System load:$(_display -value $sysPerf.ProcessorQueueLength -headlength 11)
Processes:$(_display -value $sysPerf.Processes -headlength 9 )
Users logged in:$(_display -value $(((quser).count-1)) -headlength 15)
Usage of C:$(_display -value $cusage -headlength 10)
Memory Usage:$(_display -value $memUsage -headlength 12 )
IPV4 address for $($if.InterfaceAlias):$(_display -value $IP -headlength $longest)
Swap usage:$(_display -value $swap -headlength 10)
This message is shown once a day. To disable it please create the
$hushpath file.
"@
Clear-Host
#display the welcome text and also send it to the temporary tracking file
$out | Tee-Object -FilePath $trackPath
}
Note that this is a PowerShell script file and not a function. You could enable the 24-hour tracking and put this in your PowerShell profile script. You could add or remove information. You could customize the help links. Or you can pick through the code, finding techniques and ideas for your own PowerShell scripting projects. Questions and comments are welcome. Have fun!
Some nice tips. I use a similar check to run once, but I use the existence of a powershell transcript log to check whether I need to run various things at the end of my profile script, such as checking chocolatey for updates. I used to have update-module in there too but that takes forever to run. My transcript log filename includes the date and time but my profile just checks whether there is a previous file containing the current date or not.
if (test-path (“c:\temp\powershelllogs\” + $env:username + (get-date -uformat “%y%m%d”) + “*.txt”)) {$alreadyrun = $true}
$transcriptlog = “c:\temp\powershelllogs\” + $env:username + (get-date -uformat “%y%m%d-%H%M%S”) + “.txt”
#do rest of profile script here
if (!$alreadyrun) {
choco outdated
}
That’s a handy technique. One thing that would trip me up if I wasn’t paying attention is the use of PowerShell Scheduled Jobs. I would want to make sure I was running PowerShell with -noprofile. Otherwise I could end up with misleading transcripts when running PowerShell interactively.
Amazing article as always.