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.
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.