October 2024 Scripting Challenge Solution
Last month I left you with a PowerShell scripting challenge. I hope you take the time to at least begin working on a solution. I believe that the best way to learn is by doing and figuring out a scripting problem is a fun way to learn. You may not need the end result, but hopefully you will learn something along the way and I'm betting you'll be able to use an idea or two in future projects.
The challenge was to create a PowerShell function to show all Start Menu links under C:\ProgramData\Microsoft\Windows\Start Menu\
. Your function should write an object to the pipeline that has properties that show:
- the link base name
- the link name
- The link's full name with path
- The name of the parent directory
- When the link was created
- The computer name
As an extra-credit challenge, I also asked if you could include properties that show the link's target path and description.
Let's see how you might approach this problem and I'll share my solution.
A Starting Point
Whenever I tackle a problem like this, I like to know what I'm starting with. In this case, the challenge indicates file items.
$Start = 'C:\ProgramData\Microsoft\Windows\Start Menu\'
$all = Get-ChildItem -Path $Start -Include *.lnk -Recurse
On my Windows 11 desktop, this finds 188 files.
PS C:\> $all.count
188
PS C:\> $all[0]
Directory: C:\ProgramData\Microsoft\Windows\Start Menu\Programs\1Password
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 11/15/2024 8:18 AM 1065 1Password.lnk
Ideally, the object will have properties that meet the challenge.
$all[0] | Select-Object *
You will get different results in Windows PowerShell and PowerShell 7. I'm using PowerShell 7 so my solution will be based on that version.
An alternative, to view the properties without any formatting, is to look at the underlying PSObject
.
$all[0].PSObject.properties | Select-Object Name,TypeNameOfValue,Value
Based on this information, I can get the majority of the information I need directly from the file object.
PS C:\> $all[0] | Select-Object BaseName,Name,FullName,LinkTarget,CreationTime,Directory
BaseName : 1Password
Name : 1Password.lnk
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\1Password\1Password.lnk
LinkTarget :
CreationTime : 11/15/2024 8:18:06 AM
Directory : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\1Password
However the LinkTarget
property here is not the target of the .lnk
file. This property points to the target of a symbolic link or junction. That's not this type of link. This is a shortcut link. I probably should have provided another tip, but hopefully your research led you to the legacy Wscript.Shell
COM object. At least, this is the approach I'm going to take.
The Wscript.Shell
object goes back to the days of VBScript. Fortunately, after all these years Microsoft has not removed it from Windows. Create an instance of this object.
$wshell = New-Object -ComObject 'Wscript.Shell'
The object has a method called CreateShortcut
that was intended to create shortcut files. But it will also read them.
PS C:\> $wshell.CreateShortcut($all[0].FullName)
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\1Password\1Password.lnk
Arguments :
Description :
Hotkey :
IconLocation : ,0
RelativePath :
TargetPath : C:\Program Files\1Password\app\8\1Password.exe
WindowStyle : 1
WorkingDirectory :
PS C:\> $wshell.CreateShortcut($all[10].FullName)
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Administrative Tools\Active Directory Domains and Trusts.lnk
Arguments :
Description : Manages the trust relationships between domains.
Hotkey :
IconLocation : %SystemRoot%\system32\domadmin.dll,0
RelativePath :
TargetPath : C:\WINDOWS\system32\domain.msc
WindowStyle : 1
WorkingDirectory : %HOMEDRIVE%%HOMEPATH%
You can see the additional properties I need for the challenge.
> You could use this object to update the link properties, but that's not part of this challenge.
With this information I can prototype the output with Select-Object
:
PS C:\> $all | Select-Object BaseName, @{Name = 'LinkName'; Expression = { $_.Name } },
>> @{Name = 'ParentDirectory'; Expression = { Split-Path $_.Directory -Leaf } }, FullName,
>> @{Name = 'TargetPath'; Expression = { ($wshell.CreateShortcut($_.FullName)).TargetPath } }
BaseName : 1Password
LinkName : 1Password.lnk
ParentDirectory : 1Password
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\1Password\1Password.lnk
TargetPath : C:\Program Files\1Password\app\8\1Password.exe
BaseName : Character Map
LinkName : Character Map.lnk
ParentDirectory : System Tools
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Accessories\System Tools\Character Map.lnk
TargetPath : C:\WINDOWS\system32\charmap.exe
BaseName : Remote Desktop Connection
LinkName : Remote Desktop Connection.lnk
ParentDirectory : Accessories
FullName : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Accessories\Remote Desktop Connection.lnk
TargetPath : C:\WINDOWS\system32\mstsc.exe
...
I'm splitting the parent directory to get the name of the directory. I'm using a calculated property to get the target path using the Wscript.Shell
object.
In a script, I prefer "vertical" code and tend to use this approach to define the output.
$out = foreach ($item in $all) {
$wshLink = $wshell.CreateShortcut($item.FullName)
[PSCustomObject]@{
PSTypeName = 'StartMenuLink'
Name = $item.BaseName
Path = $item.FullName
LinkName = $item.Name
ParentDirectory = Split-Path $item.Directory -Leaf
Created = $item.CreationTime
TargetPath = $wshLink.TargetPath
Description = $wshLink.Description
Computername = $env:COMPUTERNAME
}
}
I'm giving the object a type name, and I've added a few more properties. Here's a sample of the output.
PS C:\> $out | Get-Random
Name : Intel Driver & Support Assistant
Path : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Intel Driver & Support As...
LinkName : Intel Driver & Support Assistant.lnk
ParentDirectory : Programs
Created : 5/12/2023 1:32:28 AM
TargetPath : C:\Program Files (x86)\Intel\Driver and Support Assistant\DSAServiceHelper.exe
Description : Intel Driver & Support Assistant
Computername : JEFFDESK
Creating a Function
If I've been doing my development in VS Code, I've already written most of my function. All I need to do is wrap the code in a function declaration.
Function Get-StartMenuLink {
[cmdletbinding()]
[OutputType('StartMenuLink')]
Param()
#parameters for Write-Progress
$progParams = @{
Activity = $MyInvocation.MyCommand
}
$Start = 'C:\ProgramData\Microsoft\Windows\Start Menu\'
Write-Verbose "Searching $Start for start menu links"
$links = Get-ChildItem -Path $Start -Include *.lnk -Recurse
Write-Verbose "Found $($links.Count) links"
Write-Verbose "Creating the Wscript.Shell object"
$wshell = New-Object -ComObject 'Wscript.Shell'
foreach ($item in $links) {
$progParams['Status'] = $item.Name
$progParams['PercentComplete'] = ($links.IndexOf($item) / $links.Count) * 100
Write-Progress @progParams
$wshLink = $wshell.CreateShortcut($item.FullName)
Write-Verbose "Processing $($item.FullName)"
[PSCustomObject]@{
PSTypeName = 'StartMenuLink'
Path = $item.FullName
Name = $item.BaseName
LinkName = $item.Name
ParentDirectory = Split-Path $item.Directory -Leaf
Created = $item.CreationTime
TargetPath = $wshLink.TargetPath
Description = $wshLink.Description
Computername = $env:COMPUTERNAME
}
#I have inserted a delay to make the progress bar more visible
#for the sake of demonstration
Start-Sleep -Milliseconds 10
}
Write-Verbose "Finished processing start menu links"
}
My function uses Write-Progress
and has an artificial delay to make the progress bar more visible. This is just for demonstration purposes.
Here's a sample of the default output:
PS C:\> $r | Get-Random
Path : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\PowerToys (Preview)\PowerT...
Name : PowerToys (Preview)
LinkName : PowerToys (Preview).lnk
ParentDirectory : PowerToys (Preview)
Created : 11/7/2024 10:29:11 AM
TargetPath : C:\Program Files\PowerToys\PowerToys.exe
Description : PowerToys - Windows system utilities to maximize productivity
Computername : JEFFDESK
Bonus Formatting
The challenge also included a bonus to create custom formatting for your function. That's why I gave my object a custom type name.
```powershell PS C:\> $r | Get-Member
TypeName: StartMenuLink
Name MemberType Definition ---- ---------- ---------- Equals Method bool Equals(System.Object obj) GetHashCode Method int GetHashCode() GetType Method type GetType() ToString Method string ToString() Computername NoteProperty string Computername=PROSPERO Created NoteProperty datetime Created=11/15/2024 8:18:06 AM Description NoteProperty string Description= LinkName NoteProperty string LinkName=1Password.lnk Name NoteProperty System.String Name=1Password ParentDirectory NoteProperty System.String ParentDirectory=1Password Path NoteProperty string Path=C:\ProgramData\Microsoft\Windows\Start… TargetPath NoteProperty string TargetPath=C:\Program Files\1Password\app\8…
I have to stress that you __do not define__ the formatting as part of the function. Your functions should not include any of the formatting cmdlets. Instead, test your output to see what kind of formatting you would like.
```powershell
PS C:\> $r[0..2] | Format-List -GroupBy Name -Property ParentDirectory,Created,Path,TargetPath,Description
Name: 1Password
ParentDirectory : 1Password
Created : 11/15/2024 8:18:06 AM
Path : C:\ProgramData\Microsoft\Windows\Start
Menu\Programs\1Password\1Password.lnk
TargetPath : C:\Program Files\1Password\app\8\1Password.exe
Description :
Name: Character Map
ParentDirectory : System Tools
Created : 4/1/2024 3:22:46 AM
Path : C:\ProgramData\Microsoft\Windows\Start
Menu\Programs\Accessories\System Tools\Character Map.lnk
TargetPath : C:\WINDOWS\system32\charmap.exe
Description : Selects special characters and copies them to your document.
Name: Remote Desktop Connection
ParentDirectory : Accessories
Created : 4/1/2024 3:22:42 AM
Path : C:\ProgramData\Microsoft\Windows\Start
Menu\Programs\Accessories\Remote Desktop Connection.lnk
TargetPath : C:\WINDOWS\system32\mstsc.exe
Description : Use your computer to connect to a computer that is located
elsewhere and run programs or access files.
As much as I prefer tables, I know that given the length of properties I most want to see that a list is the better choice.
Once I am happy with the layout, I can use the New-PSFormatXML
function from my PSScriptTools module to create a formatting file. All I need is a sample object that has all properties defined.
($r | where description)[0] |
New-PSFormatXML -path c:\scripts\StartMenuLink.format.ps1xml -FormatType List -GroupBy Name -Properties ParentDirectory,Created,Path,TargetPath,Description
I can edit this file to customize the output further. For example, I might use $PSStyle to highlight different link types or groups. Or maybe highlight Microsoft related-links. I haven't made any of those changes yet. To test, load the file into your session.
Update-FormatData -AppendPath c:\scripts\StartMenuLink.format.ps1xml
Now, the default output is my list.
PS C:\> $r | Get-Random -Count 3
Name: Windows Performance Recorder
ParentDirectory : Windows Performance Toolkit
Created : 8/9/2022 5:35:26 PM
Path : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Windows
Kits\Windows Performance Toolkit\Windows Performance
Recorder.lnk
TargetPath : C:\Program Files (x86)\Windows Kits\10\Windows Performance
Toolkit\WPRUI.exe
Description : Windows Performance Recorder
Name: GPUView
ParentDirectory : Windows Performance Toolkit
Created : 8/9/2022 5:35:33 PM
Path : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Windows
Kits\Windows Performance Toolkit\GPUView.lnk
TargetPath : C:\Program Files (x86)\Windows Kits\10\Windows Performance
Toolkit\gpuview\GPUView.exe
Description : Shortcut to GPUView
Name: Windows PowerShell ISE (x86)
ParentDirectory : Windows PowerShell
Created : 4/1/2024 4:02:39 AM
Path : C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Windows
PowerShell\Windows PowerShell ISE (x86).lnk
TargetPath : C:\WINDOWS\syswow64\WindowsPowerShell\v1.0\PowerShell_ISE.exe
Description : Windows PowerShell Integrated Scripting Environment. Performs
object-based (command-line) functions
To make everything more portable put the ps1xml file in the same folder as the script and add this code to the end of the script file.
if (Test-Path -Path "$PSScriptRoot\StartMenuLink.format.ps1xml") {
Update-FormatData -AppendPath "$PSScriptRoot\StartMenuLink.format.ps1xml"
}
It might be nice to extend this function to let the user get links based on name or parent folder. I'll leave that fun for you.
Summary
I hope you found this to be an interesting challenge and even learned a thing or two along the way. I've put my finished script and format file in a zip file you can download from Dropbox.
If you have questions over anything I've done, please don't hesitate to leave a comment or question.