Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
November 26, 2024

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
PSObject properties
figure 1

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.

Get-StartMenuLink
figure 2

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.

Want to read the full issue?
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.