Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save patrickSchliengerCampestrini/60c86d0afe1eefa01f9790cfe20898aa to your computer and use it in GitHub Desktop.
Save patrickSchliengerCampestrini/60c86d0afe1eefa01f9790cfe20898aa to your computer and use it in GitHub Desktop.
Fetches all shell folders from Windows Registry and creates a shortcut to each, while attempting to determine the proper name and icon. Also outputs CSV file with results.
# Get All Shell Folder Shortcuts Script (Updated 8/10/2024)
# Original source: https://gist.github.com/ThioJoe/16eac0ea7d586c4edba41b454b58b225
# This PowerShell script is designed to find and create shortcuts for all special shell folders in Windows.
# These folders can be identified through their unique Class Identifiers (CLSIDs) or by their names.
# The script also generates CSV files listing these folders and associated tasks/links.
# How to Use:
# 1. Open PowerShell and navigate to the path containing this script using the 'cd' command.
# 2. Run the following command to allow running scripts for the current session:
# Set-ExecutionPolicy -ExecutionPolicy unrestricted -Scope Process
# 3. Without closing the PowerShell window, run the script by typing the name of the script file starting with .\ for example:
# .\Get_All_Shell_Folder_Shortcuts.ps1
# 4. Wait for it to finish, then check the "Shell Folder Shortcuts" folder for the generated shortcuts.
# ------------------------- OPTIONAL ARGUMENTS -------------------------
# -SaveCSV
# Switch (Takes no values)
# Save info about all the shortcuts into CSV spreadsheet files for each type of shell folder
#
# -SaveXML
# Switch (Takes no values)
# Save the XML content from shell32.dll as a file containing info about the task links
#
# -Output
# String (Optional)
# Specify a custom output folder path (relative or absolute) to save the generated shortcuts. If not provided, a folder named "Shell Folder Shortcuts" will be created in the script's directory.
#
# -DeletePreviousOutputFolder
# Switch (Takes no values)
# Delete the previous output folder before running the script if one exists matching the one that would be created
#
# -Verbose
# Switch (Takes no values)
# Enable verbose output for more detailed information during script execution
#
# -DontGroupTasks
# Switch (Takes no values)
# Prevent grouping task shortcuts, meaning the application name won't be prepended to the task name in the shortcut file
#
# -SkipCLSID
# Switch (Takes no values)
# Skip creating shortcuts for shell folders based on CLSIDs
#
# -SkipNamedFolders
# Switch (Takes no values)
# Skip creating shortcuts for named special folders
#
# -SkipTaskLinks
# Switch (Takes no values)
# Skip creating shortcuts for task links (sub-pages within shell folders and control panel menus)
#
# -DLLPath
# String (Optional)
# Specify a custom DLL file path to load the shell32.dll content from. If not provided, the default shell32.dll will be used.
# NOTE: Because of how Windows works behind the scenes, DLLs reference resources in corresponding .mui and .mun files.
# The XML data (resource ID 21) that is required in this script is actually located in shell32.dll.mun, which is located at "C:\Windows\SystemResources\shell32.dll.mun"
# This means if you want to reference the data from a DLL that is NOT at C:\Windows\System32\shell32.dll, you should directly reference the .mun file instead. It will auto redirect if it's at that exact path, but not otherwise.
# > This is especially important if wanting to reference the data from a different computer, you need to be sure to copy the .mun file
# See: https://stackoverflow.com/questions/68389730/windows-dll-function-behaviour-is-different-if-dll-is-moved-to-different-locatio
#
# ---------------------------------------------------------------------
#
# EXAMPLE USAGE FROM COMMAND LINE:
# .\Get_All_Shell_Folder_Shortcuts.ps1 -SaveXML -SaveCSV
#
# ---------------------------------------------------------------------
[CmdletBinding()]
param(
[switch]$DontGroupTasks,
[switch]$SaveXML,
[switch]$SaveCSV,
[switch]$DeletePreviousOutputFolder,
[string]$DLLPath,
[string]$Output,
[switch]$SkipCLSID,
[switch]$SkipNamedFolders,
[switch]$SkipTaskLinks
)
# Set the output folder path for the generated shortcuts based on the provided argument or default location. Convert to full path if necessary
if ($Output) {
# Convert to full path only if necessary, otherwise use as is
if (-not [System.IO.Path]::IsPathRooted($Output)) {
$mainShortcutsFolder = Join-Path $PSScriptRoot $Output
} else {
$mainShortcutsFolder = $Output
}
} else {
# Default output folder path is a subfolder named "Shell Folder Shortcuts" in the script's directory
$mainShortcutsFolder = Join-Path $PSScriptRoot "Shell Folder Shortcuts"
}
# `Join-Path` is used to construct the folder path by combining the script's root directory with the folder name.
#$mainShortcutsFolder = Join-Path $PSScriptRoot "Shell Folder Shortcuts"
# Set filenames for various output files (CSV and optional XML)
$clsidCsvPath = Join-Path $mainShortcutsFolder "CLSID_Shell_Folders.csv"
$namedFoldersCsvPath = Join-Path $mainShortcutsFolder "Named_Shell_Folders.csv"
$taskLinksCsvPath = Join-Path $mainShortcutsFolder "Task_Links.csv"
$xmlContentFilePath = Join-Path $mainShortcutsFolder "Shell32_XML_Content.xml"
$resolvedXmlContentFilePath = Join-Path $mainShortcutsFolder "Shell32_XML_Content_Resolved.xml"
# Check if the output folder already exists and delete it if the -DeletePreviousOutputFolder switch is used
if ($DeletePreviousOutputFolder -and (Test-Path $mainShortcutsFolder)) {
Write-Host "Deleting previous output folder: $mainShortcutsFolder"
Remove-Item -Path $mainShortcutsFolder -Recurse -Force
}
# `New-Item` creates the directory if it does not exist; `-Force` ensures it is created without errors if it already exists.
New-Item -Path $mainShortcutsFolder -ItemType Directory -Force | Out-Null
# Function to create a folder with a custom icon
function New-FolderWithIcon {
param (
[string]$FolderPath,
[string]$IconFile,
[string]$IconIndex # Changed to string to allow both positive and negative values
)
# Create the folder
New-Item -Path $FolderPath -ItemType Directory -Force | Out-Null
# If there's not a negative sign at the beginning of the index, add one
if ($IconIndex -notmatch '^-') {
$IconIndex = "-$IconIndex"
}
# Create desktop.ini content
$desktopIniContent = @"
[.ShellClassInfo]
IconResource=$IconFile,$IconIndex
"@
# Create desktop.ini file
$desktopIniPath = Join-Path $FolderPath "desktop.ini"
Set-Content -Path $desktopIniPath -Value $desktopIniContent -Encoding ASCII
# Set desktop.ini attributes
(Get-Item $desktopIniPath).Attributes = 'Hidden,System'
# Set folder attributes
(Get-Item $FolderPath).Attributes = 'ReadOnly,Directory'
}
# Create relevant subfolders for different types of shortcuts, and set custom icons for each folder
# Notes for choosing an icon:
# - You can use the tool 'IconsExtract' from NirSoft to see icons in a DLL file and their indexes: https://www.nirsoft.net/utils/iconsext.html
# - Another good dll to use for icons is "C:\Windows\System32\imageres.dll" which has a lot of icons
if (-not $SkipCLSID) {
$CLSIDshortcutsOutputFolder = Join-Path $mainShortcutsFolder "CLSID Shell Folder Shortcuts"
New-FolderWithIcon -FolderPath $CLSIDshortcutsOutputFolder -IconFile "C:\Windows\System32\shell32.dll" -IconIndex "20"
}
if (-not $SkipNamedFolders) {
$namedShortcutsOutputFolder = Join-Path $mainShortcutsFolder "Named Shell Folder Shortcuts"
New-FolderWithIcon -FolderPath $namedShortcutsOutputFolder -IconFile "C:\Windows\System32\shell32.dll" -IconIndex "46"
}
if (-not $SkipTaskLinks) {
$taskLinksOutputFolder = Join-Path $mainShortcutsFolder "All Task Links"
New-FolderWithIcon -FolderPath $taskLinksOutputFolder -IconFile "C:\Windows\System32\shell32.dll" -IconIndex "137"
}
# The following block adds necessary .NET types to PowerShell for later use.
# The `Add-Type` cmdlet is used to add C# code that interacts with Windows API functions.
# These functions include loading and freeing DLLs, finding and loading resources, and more.
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class Windows
{
[DllImport("user32.dll", CharSet = CharSet.Auto)]
public static extern int LoadString(IntPtr hInstance, uint uID, StringBuilder lpBuffer, int nBufferMax);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr LoadLibraryEx(string lpFileName, IntPtr hFile, uint dwFlags);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr FindResource(IntPtr hModule, int lpName, string lpType);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr LoadResource(IntPtr hModule, IntPtr hResInfo);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr LockResource(IntPtr hResData);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint SizeofResource(IntPtr hModule, IntPtr hResInfo);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool FreeLibrary(IntPtr hModule);
}
"@
# Function: Get-LocalizedString
# This function retrieves a localized (meaning in the user's language) string from a DLL based on a reference string given in the registry
# `StringReference` is a reference in the format "@<dllPath>,-<resourceId>".
function Get-LocalizedString {
param ( [string]$StringReference )
# Check if the provided string matches the expected format for a resource reference.
if ($StringReference -match '@(.+),-(\d+)') {
$dllPath = [Environment]::ExpandEnvironmentVariables($Matches[1]) # Extract and expand the DLL path.
$resourceId = [uint32]$Matches[2] # Extract the resource ID.
# Load the specified DLL into memory.
$hModule = [Windows]::LoadLibraryEx($dllPath, [IntPtr]::Zero, 0)
if ($hModule -eq [IntPtr]::Zero) {
Write-Error "Failed to load library: $dllPath"
return $null
}
# Prepare a StringBuilder object to hold the localized string.
$stringBuilder = New-Object System.Text.StringBuilder 1024
# Load the string from the DLL.
$result = [Windows]::LoadString($hModule, $resourceId, $stringBuilder, $stringBuilder.Capacity)
# Free the loaded DLL from memory. Must add '[void]' or else PowerShell will make the function return as an array.
[void][Windows]::FreeLibrary($hModule)
# If the string was successfully loaded, return it.
if ($result -ne 0) {
return $stringBuilder.ToString()
} else {
Write-Error "Failed to load string resource: $resourceId from $dllPath"
return $null
}
} else {
Write-Error "Invalid string reference format: $StringReference"
return $null
}
}
# Function: Get-FolderName
# This function retrieves the name of a shell folder given its CLSID, to be used for the shortcuts later
# It attempts to find the name by checking several potential locations in the registry.
function Get-FolderName {
param (
[string]$clsid # The CLSID of the shell folder.
)
# Initialize $nameSource to track where the folder name was found (for reporting purposes in CSV later)
$nameSource = "Unknown"
Write-Verbose "Attempting to get folder name for CLSID: $clsid"
# Step 1: Check the default value in the registry at HKEY_CLASSES_ROOT\CLSID\<clsid>.
$defaultPath = "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid"
Write-Verbose "Checking default value at: $defaultPath"
$defaultName = (Get-ItemProperty -Path $defaultPath -ErrorAction SilentlyContinue).'(default)'
# If a default name is found, check if it's a reference to a localized string.
if ($defaultName) {
Write-Verbose "Found default name: $defaultName"
if ($defaultName -match '@.+,-\d+') {
Write-Verbose "Default name is a localized string reference"
$resolvedName = Get-LocalizedString $defaultName
if ($resolvedName) {
$nameSource = "Localized String"
Write-Verbose "Resolved default name to: $resolvedName"
return @($resolvedName, $nameSource)
}
else {
Write-Verbose "Failed to resolve default name, using original value"
}
}
$nameSource = "Default Value"
return @($defaultName, $nameSource)
}
else {
Write-Verbose "No default name found"
}
# Step 2: Check for a `TargetKnownFolder` in the registry, which points to a known folder.
$initPropertyBagPath = "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid\Instance\InitPropertyBag"
Write-Verbose "Checking for TargetKnownFolder at: $initPropertyBagPath"
$targetKnownFolder = (Get-ItemProperty -Path $initPropertyBagPath -ErrorAction SilentlyContinue).TargetKnownFolder
# If a TargetKnownFolder is found, check its description in the registry.
if ($targetKnownFolder) {
Write-Verbose "Found TargetKnownFolder: $targetKnownFolder"
$folderDescriptionsPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FolderDescriptions\$targetKnownFolder"
Write-Verbose "Checking for folder name at: $folderDescriptionsPath"
$folderName = (Get-ItemProperty -Path $folderDescriptionsPath -ErrorAction SilentlyContinue).Name
if ($folderName) {
$nameSource = "Known Folder ID"
Write-Verbose "Found folder name: $folderName"
return @($folderName, $nameSource)
}
else {
Write-Verbose "No folder name found in FolderDescriptions"
}
}
else {
Write-Verbose "No TargetKnownFolder found"
}
# Step 3: Check for a `LocalizedString` value in the CLSID registry key.
$localizedStringPath = "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid"
Write-Verbose "Checking for LocalizedString at: $localizedStringPath"
$localizedString = (Get-ItemProperty -Path $localizedStringPath -ErrorAction SilentlyContinue).LocalizedString
# If a LocalizedString is found, resolve it using the Get-LocalizedString function.
if ($localizedString) {
Write-Verbose "Found LocalizedString: $localizedString"
$resolvedString = Get-LocalizedString $localizedString
if ($resolvedString) {
$nameSource = "Localized String"
Write-Verbose "Resolved LocalizedString to: $resolvedString"
return @($resolvedString, $nameSource)
}
else {
Write-Verbose "Failed to resolve LocalizedString"
}
}
else {
Write-Verbose "No LocalizedString found"
}
# Step 4: Check the Desktop\NameSpace registry key for the folder name.
$namespacePath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Desktop\NameSpace\$clsid"
Write-Verbose "Checking Desktop\NameSpace at: $namespacePath"
$namespaceName = (Get-ItemProperty -Path $namespacePath -ErrorAction SilentlyContinue).'(default)'
# If a name is found here, return it.
if ($namespaceName) {
$nameSource = "Desktop Namespace"
Write-Verbose "Found name in Desktop\NameSpace: $namespaceName"
return @($namespaceName, $nameSource)
}
else {
Write-Verbose "No name found in Desktop\NameSpace"
}
# Step 5: New check - Recursively search in HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer
$explorerPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer"
Write-Verbose "Recursively checking Explorer registry path for CLSID: $explorerPath"
function Search-RegistryKey {
param (
[string]$Path,
[string]$Clsid
)
$keys = Get-ChildItem -Path $Path -ErrorAction SilentlyContinue
foreach ($key in $keys) {
if ($key.PSChildName -eq $Clsid) {
$defaultValue = (Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue).'(default)'
if ($defaultValue -and $defaultValue -ne "") {
return $defaultValue
}
}
$subResult = Search-RegistryKey -Path $key.PSPath -Clsid $Clsid
if ($subResult) {
return $subResult
}
}
return $null
}
$explorerName = Search-RegistryKey -Path $explorerPath -Clsid $clsid
if ($explorerName) {
$nameSource = "Explorer Registry"
Write-Verbose "Found name in Explorer registry: $explorerName"
return @($explorerName, $nameSource)
}
else {
Write-Verbose "No name found in Explorer registry"
}
# Step 6: If no name is found, return the CLSID itself as a last resort to be used for the shortcut
Write-Verbose "Returning CLSID as folder name"
$nameSource = "Unknown"
return @($clsid, $nameSource)
}
# Function: Create-Shortcut
# This function creates a shortcut for a given CLSID or shell folder.
# It assigns the appropriate target path, arguments, and icon based on the CLSID information.
function Create-Shortcut {
param (
[string]$clsid, # The CLSID of the shell folder
[string]$name, # The name of the shortcut
[string]$shortcutPath, # The full path where the shortcut will be created
[string]$pageName = "" # Optional: the name of a specific page within the shell folder (usually used for control panels)
)
try {
Write-Verbose "Creating Shortcut For: $name"
# Create a COM object representing the WScript.Shell, which is used to create shortcuts
$shell = New-Object -ComObject WScript.Shell
# Create the actual shortcut at the specified path
$shortcut = $shell.CreateShortcut($shortcutPath)
# Set the 'target' to explorer so it opens with File Explorer. The 'arguments' part of the target will be set next and contain the 'shell:' part of the command.
$shortcut.TargetPath = "explorer.exe"
# If a specific page is provided, include it in the arguments, otherwise just set the shell command used to open the folder
if ($pageName) {
$shortcut.Arguments = "shell:::$clsid\$pageName"
} else {
$shortcut.Arguments = "shell:::$clsid"
}
# Attempt to find a custom icon for the shortcut by checking the registry
$iconPath = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid\DefaultIcon" -ErrorAction SilentlyContinue).'(default)'
if ($iconPath) {
Write-Verbose "Setting custom icon: $iconPath"
$shortcut.IconLocation = $iconPath
}
# Otherwise use the Windows default folder icon
else {
Write-Verbose "No custom icon found. Setting default folder icon."
$shortcut.IconLocation = "%SystemRoot%\System32\shell32.dll,3"
}
$shortcut.Save()
# Release the COM object to free up resources.
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($shell) | Out-Null
return $true
}
catch {
Write-Host "Error creating shortcut for $name`: $($_.Exception.Message)"
return $false
}
}
function Get-TaskIcon {
param (
[string]$controlPanelName,
[string]$applicationId
)
$iconPath = $null
if ($controlPanelName) {
# Try to get icon from control panel name
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\ControlPanel\NameSpace\$controlPanelName"
$iconPath = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).Icon
}
if (-not $iconPath -and $applicationId) {
# Try to get icon from application ID
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\ControlPanel\NameSpace\$applicationId"
$iconPath = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).Icon
if (-not $iconPath) {
# If not found, try CLSID path
$regPath = "Registry::HKEY_CLASSES_ROOT\CLSID\$applicationId\DefaultIcon"
$iconPath = (Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue).'(default)'
}
}
if ($iconPath) {
return Fix-CommandPath $iconPath
}
# Default icon if none found
return "%SystemRoot%\System32\shell32.dll,0"
}
# Fix issues with command paths spotted in at least one XML entry, where double percent signs were present at the beginning like %%windir%
function Fix-CommandPath {
param (
[string]$command
)
# Fix double % at the beginning
if ($command -match '^\%\%') {
$command = $command -replace '^\%\%', '%'
}
# Expand environment variables
#$command = [Environment]::ExpandEnvironmentVariables($command)
return $command
}
function Create-TaskLink-Shortcut {
param (
[string]$name,
[string]$shortcutPath,
[string]$command,
[string]$controlPanelName,
[string]$applicationId,
[string[]]$keywords
)
try {
Write-Verbose "Creating Task Link Shortcut For: $name"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
# Parse the command
if ($command -match '^(\S+)\s*(.*)$') {
$targetPath = Fix-CommandPath $Matches[1]
$arguments = Fix-CommandPath $Matches[2]
$shortcut.TargetPath = $targetPath
$shortcut.Arguments = $arguments
} else {
$fixedCommand = Fix-CommandPath $command
$shortcut.TargetPath = $fixedCommand
}
$iconPath = Get-TaskIcon -controlPanelName $controlPanelName -applicationId $applicationId
$shortcut.IconLocation = $iconPath
# Combine keywords into a single string and set as Description
if ($keywords -and $keywords.Count -gt 0) {
$descriptionLimit = 259 # Limit for Description field in shortcuts or else it causes some kind of buffer overflow
$keywordString = ""
$separator = " "
foreach ($keyword in $keywords) {
$potentialNewString = if ($keywordString) { $keywordString + $separator + $keyword } else { $keyword }
if ($potentialNewString.Length -le $descriptionLimit) {
$keywordString = $potentialNewString
} else {
break
}
}
$shortcut.Description = $keywordString
}
$shortcut.Save()
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) | Out-Null
return $true
} catch {
Write-Host "Error creating shortcut for $name`: $($_.Exception.Message)"
return $false
}
}
# Function: Get-Shell32XMLContent
# This function extracts and returns the XML content embedded in the shell32.dll file, which contains info about sub-pages within certain shell folders and control panel menus.
# Apparently these are sometimes referred to as "Task Links"
function Get-Shell32XMLContent {
param(
[switch]$SaveXML,
[string]$CustomDLL
)
# If a custom DLL path is provided, use it; otherwise, use the default shell32.dll path
if ($CustomDLL) {
Write-Verbose "Using custom DLL path: $CustomDLL"
$dllPath = $CustomDLL
} else {
Write-Verbose "Using default shell32.dll path"
$dllPath = "shell32.dll"
}
# Constants used for loading the shell32.dll as a data file.
$LOAD_LIBRARY_AS_DATAFILE = 0x00000002
$DONT_RESOLVE_DLL_REFERENCES = 0x00000001
# Initialize an empty string to hold the XML content
$xmlContent = ""
# Load shell32.dll as a data file, preventing the DLL from being fully resolved as it is not necessary
$shell32Handle = [Windows]::LoadLibraryEx($dllPath, [IntPtr]::Zero, $LOAD_LIBRARY_AS_DATAFILE -bor $DONT_RESOLVE_DLL_REFERENCES)
if ($shell32Handle -eq [IntPtr]::Zero) {
Write-Error "Failed to load $dllPath"
return $null
}
try {
# Attempt to find the XML resource within shell32.dll. '21' is necessary to use here.
$hResInfo = [Windows]::FindResource($shell32Handle, 21, "XML")
if ($hResInfo -eq [IntPtr]::Zero) {
Write-Error "Failed to find XML resource"
Write-Error "Did you move the DLL from the original location? If so you may need to directly specify the corresponding .mun file instead of the DLL."
Write-Error "See the comments for the DLLPath argument at the top of the script for more info."
return $null
}
# Load the XML resource data.
$hResData = [Windows]::LoadResource($shell32Handle, $hResInfo)
if ($hResData -eq [IntPtr]::Zero) {
Write-Error "Failed to load XML resource"
return $null
}
# Lock the resource in memory to access its data.
$pData = [Windows]::LockResource($hResData)
if ($pData -eq [IntPtr]::Zero) {
Write-Error "Failed to lock XML resource"
return $null
}
# Get the size of the XML resource and copy it into a byte array.
$size = [Windows]::SizeofResource($shell32Handle, $hResInfo)
$byteArray = New-Object byte[] $size
[System.Runtime.InteropServices.Marshal]::Copy($pData, $byteArray, 0, $size)
# Convert the byte array to a UTF-8 string.
$xmlContent = [System.Text.Encoding]::UTF8.GetString($byteArray)
$xmlContent = $xmlContent -replace "`0", "" # Remove any null characters from the string, though this probably isn't necessary
}
finally {
# Ensure that the loaded DLL is always freed from memory.
[void][Windows]::FreeLibrary($shell32Handle)
}
# Clean and trim any extraneous whitespace from the XML content
$xmlContent = $xmlContent.Trim()
# Save XML content if the SaveXML switch is used
if ($SaveXML) {
Save-PrettyXML -xmlContent $xmlContent -outputPath $xmlContentFilePath
}
# Return the XML content as a string
return $xmlContent
}
# Save the XML content from shell32.dll to a file for reference if the user uses the -SaveXML switch
function Save-PrettyXML {
param (
[string]$xmlContent,
[string]$outputPath
)
try {
# Load the XML content
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.LoadXml($xmlContent)
# Create XmlWriterSettings for pretty-printing
$writerSettings = New-Object System.Xml.XmlWriterSettings
$writerSettings.Indent = $true
$writerSettings.IndentChars = " "
$writerSettings.NewLineChars = "`r`n"
$writerSettings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
# Create XmlWriter and write the document
$writer = [System.Xml.XmlWriter]::Create($outputPath, $writerSettings)
$xmlDoc.Save($writer)
$writer.Close()
Write-Verbose "XML content formatted and saved: $outputPath"
}
catch {
Write-Error "Failed to format and save XML: $_"
}
}
# Function: Get-TaskLinks
# This function parses the XML content extracted from shell32.dll to find "task links", which are basically sub-menu pages, often in the Control Panel
function Get-TaskLinks {
param(
[switch]$SaveXML,
[string]$DLLPath
)
$xmlContent = Get-Shell32XMLContent -SaveXML:$SaveXML -CustomDLL:$DLLPath
try {
$xml = [xml]$xmlContent
Write-Verbose "XML parsed successfully."
} catch {
Write-Error "Failed to parse XML content: $_"
return
}
# Create a copy of the XML for resolved content
$resolvedXml = $xml.Clone()
$nsManager = New-Object System.Xml.XmlNamespaceManager($xml.NameTable)
$nsManager.AddNamespace("cpl", "http://schemas.microsoft.com/windows/cpltasks/v1")
$nsManager.AddNamespace("sh", "http://schemas.microsoft.com/windows/tasks/v1")
$nsManager.AddNamespace("sh2", "http://schemas.microsoft.com/windows/tasks/v2")
$tasks = @()
$allTasks = $xml.SelectNodes("//sh:task", $nsManager)
foreach ($task in $allTasks) {
$taskId = $task.GetAttribute("id")
$nameNode = $task.SelectSingleNode("sh:name", $nsManager)
$controlPanel = $task.SelectSingleNode("sh2:controlpanel", $nsManager)
$commandNode = $task.SelectSingleNode("sh:command", $nsManager)
$keywordsNodes = $task.SelectNodes("sh:keywords", $nsManager)
# Resolve name
$name = $null
if ($nameNode -and $nameNode.InnerText) {
if ($nameNode.InnerText -match '@(.+),-(\d+)') {
$name = Get-LocalizedString $nameNode.InnerText
# Update resolved XML
$resolvedNameNode = $resolvedXml.SelectSingleNode("//sh:task[@id='$taskId']/sh:name", $nsManager)
if ($resolvedNameNode) {
$resolvedNameNode.InnerText = $name
}
} else {
$name = $nameNode.InnerText
}
}
if ($name) {
$name = $name.Trim()
} elseif ($task.Name -eq "sh:task" -and $task.GetAttribute("idref")) {
Write-Verbose "Skipping category entry: $($task.OuterXml)"
continue
} else {
Write-Warning "Task $taskId is missing a name and is not a category reference. This may indicate an issue: $($task.OuterXml)"
continue
}
$command = $null
$appName = $null
$page = $null
$appId = $task.ParentNode.id
if (-not $appId) {
$appId = $task.GetAttribute("id")
}
if ($controlPanel) {
$appName = $controlPanel.GetAttribute("name")
$page = $controlPanel.GetAttribute("page")
$command = "control.exe /name $appName"
if ($page) {
$command += " /page $page"
}
} elseif ($commandNode) {
$command = $commandNode.InnerText
}
$keywords = @()
foreach ($keywordNode in $keywordsNodes) {
$keyword = $null
if ($keywordNode.InnerText -match '@(.+),-(\d+)') {
$keyword = Get-LocalizedString $keywordNode.InnerText
# Update resolved XML
$resolvedKeywordNode = $resolvedXml.SelectSingleNode("//sh:task[@id='$taskId']/sh:keywords[text()='$($keywordNode.InnerText)']", $nsManager)
if ($resolvedKeywordNode) {
$resolvedKeywordNode.InnerText = $keyword
}
} else {
$keyword = $keywordNode.InnerText
}
if ($keyword) {
$splitKeywords = $keyword.Split(';', [StringSplitOptions]::RemoveEmptyEntries)
foreach ($splitKeyword in $splitKeywords) {
if ($splitKeyword) {
$keywords += $splitKeyword.Trim()
}
}
}
}
# If no app name, look it up via CLSID in the registry
if (-not $appName) {
$appName = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\CLSID\$appId" -ErrorAction SilentlyContinue)."System.ApplicationName"
}
# If still no app name, check if any other task has the same app ID with a name, and if so use that, but only the first instance
if (-not $appName) {
$otherTask = $tasks | Where-Object { $_.ApplicationId -eq $appId -and $_.ApplicationName } | Select-Object -First 1
if ($otherTask) {
$appName = $otherTask.ApplicationName
}
}
if ($name -and ($command -or $appName)) {
# Determine the ControlPanelName value before creating the object
if ($controlPanel) {
$controlPanelName = $controlPanel.GetAttribute("name")
} else {
$controlPanelName = $null
}
# Now create the $newTask object using the pre-determined $controlPanelName
$newTask = [PSCustomObject]@{
TaskId = $taskId
ApplicationId = $appId
Name = $name
ApplicationName = $appName
Page = $page
Command = $command
Keywords = $keywords
ControlPanelName = $controlPanelName
}
# Check if a task with the same name and command already exists
$isDuplicate = $tasks | Where-Object { $_.Name -eq $newTask.Name -and $_.Command -eq $newTask.Command }
if (-not $isDuplicate) {
$tasks += $newTask
} else {
Write-Verbose "Skipping duplicate task: $($newTask.Name)"
}
}
}
# Store the resolved XML content in a new variable
$resolvedXmlContent = $resolvedXml.OuterXml
# Save XML content if the SaveXML switch is used
if ($SaveXML) {
Save-PrettyXML -xmlContent $resolvedXmlContent -outputPath $resolvedXmlContentFilePath
}
return $tasks
}
# Function: Create-NamedShortcut
# This function creates a shortcut for a named special folder.
# The shortcut points directly to the folder using its name (e.g., "Documents", "Pictures").
function Create-NamedShortcut {
param (
[string]$name, # The name of the special folder.
[string]$shortcutPath, # The full path where the shortcut will be created.
[string]$iconPath # The path to the folder's custom icon (if any).
)
try {
Write-Verbose "Creating named shortcut for $name"
# Create a COM object representing the WScript.Shell, which is used to create shortcuts.
$shell = New-Object -ComObject WScript.Shell
# Create the actual shortcut at the specified path.
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = "explorer.exe" # The shortcut will open in Windows Explorer.
$shortcut.Arguments = "shell:$name" # Set the shortcut to open the specified folder by name. This will also be in the target path box for the shortcut.
# Set the custom icon if one is provided in the registry
if ($iconPath) {
Write-Verbose "Setting custom icon: $iconPath"
$shortcut.IconLocation = $iconPath
}
else {
Write-Verbose "Setting default folder icon"
$shortcut.IconLocation = "%SystemRoot%\System32\shell32.dll,3" # Default folder icon.
}
# Save the shortcut.
$shortcut.Save()
# Release the COM object to free up resources.
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($shell) | Out-Null
return $true
}
catch {
Write-Host "Error creating shortcut for $name`: $($_.Exception.Message)"
return $false
}
}
# Function: Create-CLSIDCsvFile
# This function generates a CSV file containing details about all processed CLSID shell folders.
function Create-CLSIDCsvFile {
param (
[string]$outputPath, # The full path where the CSV file will be saved.
[array]$clsidData # An array of objects containing CLSID data.
)
# Initialize the CSV content with headers.
$csvContent = "CLSID,ExplorerCommand,Name,NameSource,CustomIcon`n"
# Loop through each CLSID data object and append its details to the CSV content.
foreach ($item in $clsidData) {
$explorerCommand = "explorer shell:::$($item.CLSID)" # The command to open the shell folder.
$iconPath = if ($item.IconPath) {
"`"$($item.IconPath -replace '"', '""')`"" # Escape double quotes in the icon path.
} else {
"None"
}
# Escape any double quotes in the name.
$escapedName = $item.Name -replace '"', '""'
# Convert the sub-items array to a string, separating items with a pipe character.
#$subItemsString = ($item.SubItems | ForEach-Object { "$($_.Name):$($_.Page)" }) -join '|'
# Append the CLSID details to the CSV content.
$csvContent += "$($item.CLSID),`"$explorerCommand`",`"$escapedName`",$($item.NameSource),$iconPath`n"
}
# Write the CSV content to the specified output file.
$csvContent | Out-File -FilePath $outputPath -Encoding utf8
}
# Function: Create-NamedFoldersCsvFile
# This function generates a CSV file containing details about all processed named special folders.
function Create-NamedFoldersCsvFile {
param (
[string]$outputPath # The full path where the CSV file will be saved.
)
# Initialize the CSV content with headers.
$csvContent = "Name,ExplorerCommand,RelativePath,ParentFolder`n"
# Retrieve all named special folders from the registry.
$namedFolders = Get-ChildItem -Path "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FolderDescriptions"
# Loop through each named folder and append its details to the CSV content.
foreach ($folder in $namedFolders) {
$folderProperties = Get-ItemProperty -Path $folder.PSPath
$name = $folderProperties.Name # Extract the name of the folder.
if ($name) {
$explorerCommand = "explorer shell:$name" # The command to open the folder.
$relativePath = $folderProperties.RelativePath -replace ',', '","' # Escape commas in the relative path.
$parentFolderGuid = $folderProperties.ParentFolder # Extract the parent folder GUID (if any).
$parentFolderName = "None" # Default value if there is no parent folder.
if ($parentFolderGuid) {
# If a parent folder GUID is found, retrieve the name of the parent folder.
$parentFolderPath = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FolderDescriptions\$parentFolderGuid"
$parentFolderName = (Get-ItemProperty -Path $parentFolderPath -ErrorAction SilentlyContinue).Name
}
# Append the named folder details to the CSV content.
$csvContent += "`"$name`",`"$explorerCommand`",`"$relativePath`",`"$parentFolderName`"`n"
}
}
# Write the CSV content to the specified output file.
$csvContent | Out-File -FilePath $outputPath -Encoding utf8
}
# Function: Create-TaskLinksCsvFile
# This function generates a CSV file containing details about all processed 'task links' aka sub-pages.
function Create-TaskLinksCsvFile {
param (
[string]$outputPath,
[array]$taskLinksData
)
$csvContent = "XMLTaskId,ApplicationId,ApplicationName,Name,Page,Command,Keywords`n"
foreach ($item in $taskLinksData) {
$taskId = $item.TaskId -replace '"', '""'
$applicationId = $item.ApplicationId -replace '"', '""'
$applicationName = $item.ApplicationName -replace '"', '""'
$name = $item.Name -replace '"', '""'
$page = $item.Page -replace '"', '""'
$command = $item.Command -replace '"', '""'
$keywords = ($item.Keywords -join ';') -replace '"', '""'
$csvContent += "`"$taskId`",`"$applicationId`",`"$applicationName`",`"$name`",`"$page`",`"$command`",`"$keywords`"`n"
}
$csvContent | Out-File -FilePath $outputPath -Encoding utf8
}
# Take the app name like Microsoft.NetworkAndSharingCenter and prepare it to be displayed in the shortcut name like "Network and Sharing Center - Whatever Task name"
function Prettify-App-Name {
param(
[string]$AppName,
[string]$TaskName
)
# List of words to rejoin if split
$wordsToRejoin = @(
"Bit Locker",
"Side Show",
"Auto Play"
# Add more words as needed
)
# Remove "Microsoft." prefix if present
$AppName = $AppName -replace '^Microsoft\.', ''
# Split camelCase into separate words, handling consecutive uppercase letters
$AppName = $AppName -creplace '(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|\b(?=[A-Z]{2,}\b)', ' '
# Rejoin specific words
foreach ($word in $wordsToRejoin) {
$AppName = $AppName -replace $word, $word.Replace(' ', '')
}
# Combine AppName and TaskName
$PrettyName = "$AppName - $TaskName"
# Sanitize the name to remove invalid characters for file names
$PrettyName = $PrettyName -replace '[\\/:*?"<>|]', '_'
return $PrettyName
}
# ---------------------------------------------- ----------------------------------------------------------------
# ---------------------------------------------- Main Script ----------------------------------------------
# ---------------------------------------------- ----------------------------------------------------------------
# If statement to check if CLSID is skipped by argument
if (-not $SkipCLSID) {
# Retrieve all CLSIDs with a "ShellFolder" subkey from the registry.
# These CLSIDs represent shell folders that are embedded within Windows.
$shellFolders = Get-ChildItem -Path 'Registry::HKEY_CLASSES_ROOT\CLSID' |
Where-Object {$_.GetSubKeyNames() -contains "ShellFolder"} |
Select-Object PSChildName
Write-Host "`n----- Processing $($shellFolders.Count) Shell Folders -----"
# Create an array to store information about each CLSID (name, icon, etc.).
$clsidInfo = @()
# Loop through each relevant CLSID that was found and process it to create shortcuts.
foreach ($folder in $shellFolders) {
$clsid = $folder.PSChildName # Extract the CLSID.
Write-Verbose "Processing CLSID: $clsid"
# Retrieve the name of the shell folder using the Get-FolderName function and the source of the name within the registry
$resultArray = Get-FolderName -clsid $clsid
$name = $resultArray[0]
$nameSource = $resultArray[1]
# Sanitize the folder name to make it a valid filename.
$sanitizedName = $name -replace '[\\/:*?"<>|]', '_'
$shortcutPath = Join-Path $CLSIDshortcutsOutputFolder "$sanitizedName.lnk"
Write-Verbose "Attempting to create shortcut: $shortcutPath"
$success = Create-Shortcut -clsid $clsid -name $name -shortcutPath $shortcutPath
if ($success) {
Write-Host "Created CLSID Shortcut For: $name"
}
else {
Write-Host "Failed to create shortcut for $name"
}
# Check for sub-items (pages) related to the current CLSID (e.g., control panel items).
$appName = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid" -ErrorAction SilentlyContinue)."System.ApplicationName"
# Store the CLSID information for later use (e.g., in CSV file generation).
$iconPath = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid\DefaultIcon" -ErrorAction SilentlyContinue).'(default)'
$clsidInfo += [PSCustomObject]@{
CLSID = $clsid
Name = $name
NameSource = $nameSource
IconPath = $iconPath
}
}
}
if (-not $SkipNamedFolders) {
# Retrieve all named special folders from the registry.
$namedFolders = Get-ChildItem -Path "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FolderDescriptions"
Write-Host "`n----- Processing $($namedFolders.Count) Special Named Folders -----"
# Loop through each named folder and create a shortcut for it.
foreach ($folder in $namedFolders) {
$folderProperties = Get-ItemProperty -Path $folder.PSPath
$folderName = $folderProperties.Name # Extract the name of the folder.
$iconPath = $folderProperties.Icon # Extract the custom icon path (if any).
if ($folderName) {
Write-Verbose "Processing named folder: $folderName"
# Sanitize the folder name to make it a valid filename.
$sanitizedName = $folderName -replace '[\\/:*?"<>|]', '_'
$shortcutPath = Join-Path $namedShortcutsOutputFolder "$sanitizedName.lnk"
Write-Verbose "Attempting to create shortcut: $shortcutPath"
$success = Create-NamedShortcut -name $folderName -shortcutPath $shortcutPath -iconPath $iconPath
if ($success) {
Write-Host "Created Shortcut For Named Folder: $folderName"
}
else {
Write-Host "Failed to create shortcut for named folder $folderName"
}
}
else {
Write-Verbose "Skipping folder with no name: $($folder.PSChildName)"
}
}
}
if (-not $SkipTaskLinks) {
# Process Task Links - Use the extracted XML data from Shell32 to create shortcuts for task links
Write-Host "`n -----Processing Task Links -----"
# Retrieve task links from the XML content in shell32.dll.
$taskLinks = Get-TaskLinks -SaveXML:$SaveXML -DLLPath:$DLLPath
$createdShortcutNames = @{} # Track created shortcuts to be able to tasks with the same name but different commands by appending a number
foreach ($task in $taskLinks) {
$originalName = $task.Name
# Use Prettify-App-Name function by default, unless DontGroupTasks is specified
if (-not $DontGroupTasks -and $task.ApplicationName) {
$sanitizedName = Prettify-App-Name -AppName $task.ApplicationName -TaskName $originalName
} else {
$sanitizedName = $originalName -replace '[\\/:*?"<>|]', '_'
}
# Check if a shortcut with this name already exists, if so set a unique number to the end of the name
$nameCounter = 1
$uniqueName = $sanitizedName
while ($createdShortcutNames.ContainsKey($uniqueName)) {
$nameCounter++
$uniqueName = "${sanitizedName} ($nameCounter)"
}
$shortcutPath = Join-Path $taskLinksOutputFolder "$uniqueName.lnk"
$createdShortcutNames[$uniqueName] = $true
# Determine the command based on available information. Some task XML entries have the entire command already given, others are implied to be used with control.exe
if ($task.Command) {
$command = $task.Command
} elseif ($task.ApplicationName -and $task.Page) {
$command = "control.exe /name $($task.ApplicationName) /page $($task.Page)"
} else {
Write-Verbose "Skipping task $originalName due to insufficient command information"
continue
}
$success = Create-TaskLink-Shortcut -name $uniqueName -shortcutPath $shortcutPath -command $command -controlPanelName $task.ControlPanelName -applicationId $task.ApplicationId -keywords $task.Keywords
if ($success) {
Write-Host "Created task link shortcut for $uniqueName"
} else {
Write-Host "Failed to create task link shortcut for $uniqueName"
}
}
}
# Create the CSV files using the stored CLSID and 'task link' (aka menu sub-pages) data. Skip each depending on the corresponding switch.
$CLSIDDisplayPath = ""
$namedFolderDisplayPath = ""
$taskLinksDisplayPath = ""
if ($SaveCSV -and -not $SkipCLSID) {
Create-CLSIDCsvFile -outputPath $clsidCsvPath -clsidData $clsidInfo
$CLSIDDisplayPath = "`n" + $clsidCsvPath
}
if ($SaveCSV -and -not $SkipNamedFolders) {
Create-NamedFoldersCsvFile -outputPath $namedFoldersCsvPath
$namedFolderDisplayPath = "`n" + $namedFoldersCsvPath
}
if ($SaveCSV -and -not $SkipTaskLinks) {
Create-TaskLinksCsvFile -outputPath $taskLinksCsvPath -taskLinksData $taskLinks
$taskLinksDisplayPath = "`n" + $taskLinksCsvPath
}
# Output a message indicating that the script execution is complete and the CSV files have been created.
Write-Host "`n*** Script execution complete ***`n"
# If SaveXML switch was used, also output the paths to the saved XML files
if ($SaveXML -and (-not $SkipTaskLinks)) {
Write-Host "XML files have been saved at:`n$xmlContentFilePath`n$resolvedXmlContentFilePath`n"
}
# As long as any of the CSV files were created, output the paths to them - check by seeing if strings are empty
if ($CLSIDDisplayPath -or $namedFolderDisplayPath -or $taskLinksDisplayPath){
$csvPrintString = "CSV files have been created at:" + "$CLSIDDisplayPath" + "$namedFolderDisplayPath" + "$taskLinksDisplayPath" + "`n"
Write-Host $csvPrintString
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment