Copy the target of a shortcut file (*.lnk) when the target path contains emoji characters
Asked Answered
C

2

2

My goal is to write a simple Powershell script that will take one mandatory argument, that argument must be a full file path to a shortcut (.lnk) file, then the script will resolve the shortcut's target item (a file or a directory) and copy it into the current working directory of the script.

The problem I found is when testing a shortcut whose target item points to a file or folder that contains emoji chars in the path, like for example:

"C:\Movies\β€’ Unidentified\[πŸ‡ͺπŸ‡Έ]\Amor, curiosidad, prozak y dudas (2001)\File.mkv"

Firstly I've tried with Copy-Item cmdlet, and after that I tried with Shell.NameSpace + Folder.CopyHere() method from Windows Shell Scripting as shown in this example:

https://mcmap.net/q/472836/-invoke-windows-copy-from-powershell

That methodology is what I finally pretend to use for this script instead of Copy-Item cmdlet, because it displays the default file progress UI and I prefer it for this reason.

Note that I'm not very experienced with PowerShell, but in both cases the Copy-Item cmdlet and the CopyHere method are executed without giving any exception message, it just does not perform the file copy operation.

If the item path of the shortcut's target item does not contain emoji chars, it works fine.

I'm not sure if it's some kind of encoding issue. My default O.S encoding is Windows-1252.

What I'm doing wrong and how can I fix this issue?.

# Takes 1 mandatory argument pointing to a shortcut (.lnk) file, 
# resolves the shortcut's target item (a file or directory), 
# and copies that target item to the specified destination folder 
# using Windows default IFileOperation progress UI.

# - File copy method took from here:
#   https://mcmap.net/q/472836/-invoke-windows-copy-from-powershell

# - "Shell.NameSpace" method and "Folder" object Docs:
#   https://learn.microsoft.com/en-us/windows/win32/shell/shell-namespace
#   https://learn.microsoft.com/en-us/windows/win32/shell/folder

param (
    [Parameter(
        Position=0,
        Mandatory, 
        ValueFromPipeline, 
        HelpMessage="Enter the full path to a shortcut (.lnk) file.")
    ] [string] $linkFile = "",
    [Parameter(
        Position=1,
        ValueFromPipeline, 
        HelpMessage="Enter the full path to the destination folder.")
    ] [string] $destinationFolder = $(Get-Location)
)

$wsShell    = New-Object -ComObject WScript.Shell
$shellApp   = New-Object -ComObject Shell.Application
$targetItem = $wsShell.CreateShortcut($linkFile).TargetPath

Write-Host [i] Link File..: ($linkFile)
Write-Host [i] Target Item: ($targetItem)
Write-Host [i] Destination: ($destinationFolder)
Write-Host [i] Copying target item to destination folder...
$shellApp.NameSpace("$destinationFolder").CopyHere("$targetItem")
Write-Host [i] Copy operation completed.

#[System.Console]::WriteLine("Press any key to exit...")
#[System.Console]::ReadKey($true)
Exit(0)

UPDATE

I've put all this after the param block and nothing has changed:

[Text.Encoding] $encoding                      = [Text.Encoding]::UTF8
[console]::InputEncoding                       = $encoding
[console]::OutputEncoding                      = $encoding
$OutputEncoding                                = $encoding
$PSDefaultParameterValues['Out-File:Encoding'] = $encoding
$PSDefaultParameterValues['*:Encoding']        = $encoding
Chrysolite answered 8/12, 2022 at 10:30 Comment(14)
emoji are unicode characters. See : #67104474 – Mini
But how do I fix it?. I've tried setting the unicode codepage "CHCP 65001" in the CMD instance from where I run the powershell script. I also tried sending the '$targetItem' string variable (that contains the path with the emoji chars) with 'Set-Content' cmdlet to a text file using -encoding parameter with "unicode" (utf16) and "utf8" values, but the emoji is replaced with question mark characters. I'm reading the text file with Sublime text, and I should see "[πŸ‡ͺπŸ‡Έ]" instead of "[????]". – Chrysolite
It looks like the shell COM object already has a problem to read the Unicode target path. $targetItem already displays with ??. – Miniver
Everything is working. Your real issue is the font that is being used to view emoji doesn't contain the emoji character. You need to change the font in your console to a font that support emoji. Console default to Ascii Font and will filter out the emoji. – Mini
@Miniver Yes the "Write-Host [i] Target Item: ($targetItem)" command displays "??" in the CMD instance. – Chrysolite
@Mini It is not working, the file is not copied, the UI progress is not shown. I really don't care of displaying the proper Unicode characters in the CMD instance or the PowerShell instance, my main problem is that the file copy operation is not performed when a path contains an emoji. And it does not give any error message. – Chrysolite
The file path that I provided in my post contains another Unicode character, the bullet char (β€’) and when I tested a path containing that character it does not cause any problem as said by @jdweng. It is when the path contains an emoji when occurs the problem that I described. – Chrysolite
@Chrysolite If it were a font issue, you would likely see a single Unicode replacement character per emoji character: οΏ½. The encoding problem definitely happens earlier. You can also write $targetItem to a file and get the same result: $targetItem | Set-Content test.txt -Encoding utf8 – Miniver
Instead of copying with COM $shellApp.NameSpace("$destinationFolder").CopyHere("$targetItem"), did you try the Copy-Item cmdlet with parameter -LiteralPath $targetItem ? – Witted
@Witted It's not the copying that is problematic. The encoding of the $targetItem value is already broken. – Miniver
@Miniver I was not able to adapt it to the code that I provided in the main post, could you please share an example for this scenario?. I do: $Folder = Split-Path $targetItem and when I try to set this object $shellApp.NameSpace($Folder) it becomes NUL. So I can't go forward to try: $Item = $objFolder.Items().Item($File) and to finally try: $Target = $objFolder.GetDetailsOf($Item, 203) – Chrysolite
@Miniver Forget about what I said. Now I figured I needed to do all that with $linkFile and not with $targetItem. It Works!!!!. Thanks a lot. But I still have to test a few paths to make sure everything works as expected. – Chrysolite
There's 2 of them, 0x1F1EA - REGIONAL INDICATOR SYMBOL LETTER E and 0x1F1F8 - REGIONAL INDICATOR SYMBOL LETTER S. – Bold
Also, 0x2022 - BULLET. – Bold
M
2

As discussed in comments, the Shell.CreateShortcut method seems to have an encoding issue when it comes to emoji (the root cause is propably missing support of UTF-16 surrogate pairs). The value of the variable $targetItem already contains ?? in place of the emoji character. The proof is that this does not only show in the console, but also if you write the value to an UTF-8 encoded file.

As a workaround, you may use the FolderItem2.ExtendedProperty(String) method. This allows you to query a plethora of shell properties. The one we are interested in is System.Link.TargetParsingPath.

Function Get-ShellProperty {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'PSPath')]
        [string[]] $LiteralPath,

        [Parameter(Mandatory)]
        [string[]] $Property
    )
    begin{
        $Shell = New-Object -ComObject Shell.Application
    }
    process{
        foreach( $filePath in $LiteralPath ) {
            $fsPath = Convert-Path -LiteralPath $filePath
            $nameSpace = $Shell.NameSpace(( Split-Path $fsPath ))       
            $file = $nameSpace.ParseName(( Split-Path $fsPath -Leaf ))

            # Query the given shell properties and output them as a new object
            $ht = [ordered] @{ Path = $filePath }
            foreach( $propId in $Property ) {
                $ht[ $propId ] = $file.ExtendedProperty( $propId )
            }
            [PSCustomObject] $ht
        }
    }
}

Usage:

$properties = Get-ShellProperty $linkFile -Property System.Link.TargetParsingPath
$targetItem = $properties.'System.Link.TargetParsingPath'

You may also query multiple properties with one call:

$properties = Get-ShellProperty $linkFile -Property System.Link.TargetParsingPath, System.Link.Arguments
$targetItem = $properties.'System.Link.TargetParsingPath'
$arguments  = $properties.'System.Link.Arguments'
Miniver answered 8/12, 2022 at 18:3 Comment(4)
This list of system property identifiers will be of help: https://mcmap.net/q/472838/-what-options-are-available-for-shell32-folder-getdetailsof - and the code to obtain them. – Chrysolite
@Chrysolite Thanks, but are these values constant between Windows versions? Could they be even different on the same system, depending on the folder? – Miniver
@Chrysolite This question suggested that the column numbers can indeed change between Windows versions. I have replaced the code by the more reliable method which doesn't require magic numbers. – Miniver
Nice job, but that api is yuk. – Bold
C
0

This is the code in its final state. Thanks to @zett42 for giving a solution in his answer.

This script can be useful if launched silently / hidden with WScript.exe via a custom context-menu option added through Windows registry for lnkfile file type. Or for other kind of needs.

LinkTargetCopier.ps1

# Takes 1 mandatory argument pointing to a shortcut (.lnk) file, 
# resolves the shortcut's target item (a file or directory), 
# and copies that target item to the specified destination folder 
# using Windows default IFileOperation progress UI.

# - File copy methodology took from here:
#   https://mcmap.net/q/472836/-invoke-windows-copy-from-powershell

# - "Shell.NameSpace" method and "Folder" object Docs:
#   https://learn.microsoft.com/en-us/windows/win32/shell/shell-namespace
#   https://learn.microsoft.com/en-us/windows/win32/shell/folder

# - Link's target character encoding issue discussion:
#   https://mcmap.net/q/469711/-copy-the-target-of-a-shortcut-file-lnk-when-the-target-path-contains-emoji-characters

param (
    [Parameter(
     Position=0, ValueFromPipeline, Mandatory, 
     HelpMessage="Enter the full path to a shortcut (.lnk) file.")]
    [Alias("lnk", "link", "shortcut", "shortcutFile")]
    [String] $linkFile = "",

    [Parameter(
     Position=1, ValueFromPipeline, 
     HelpMessage="Enter the full path to the destination directory.")]
    [Alias("dest", "destination", "target", "targetDir")] 
    [String] $destinationDir = $(Get-Location)
)

# https://mcmap.net/q/472840/-determine-if-script-is-running-hidden
If (-not ([System.Management.Automation.PSTypeName]'My_User32').Type) {
Add-Type -Language CSharp -TypeDefinition @"
    using System.Runtime.InteropServices;
    public class My_User32
    { 
        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool IsWindowVisible(int hWnd);
    }
"@
}

$host.ui.RawUI.WindowTitle = "LinkTargetCopier"
[System.Console]::OutputEncoding = [System.Text.Encoding]::Default

Set-Variable linkTargetPropId -Option Constant -Value ([Int32] 203)
# List with more system property ids: https://mcmap.net/q/472838/-what-options-are-available-for-shell32-folder-getdetailsof

[Object] $wsShell  = New-Object -ComObject WScript.Shell
[Object] $shellApp = New-Object -ComObject Shell.Application

[String] $lnkDirectoryPath = Split-Path $linkFile
[String] $lnkFileName      = Split-Path $linkFile -Leaf

[Object] $objFolder  = $shellApp.NameSpace($lnkDirectoryPath)
[Object] $folderItem = $objFolder.Items().Item($lnkFileName)
[String] $linkTarget = $objFolder.GetDetailsOf($folderItem, $linkTargetPropId)

$proc = [System.Diagnostics.Process]::GetCurrentProcess()
[boolean] $isVisible = [My_User32]::IsWindowVisible($proc.MainWindowHandle)

Write-Host [i] Link File..: ($linkFile)
Write-Host [i] Target Item: ($linkTarget)
Write-Host [i] Destination: ($destinationDir)

if(!(Test-Path -LiteralPath "$linkTarget" -PathType "Any" )){
    Write-Host [i] Target item does not exist. Program will terminate now.
    [Int32] $exitCode = 1
    # If process is running hidden...
    if($isVisible) {
        [System.Console]::WriteLine("Press any key to exit...")
        [System.Console]::ReadKey($true)

    } else {
        [System.Windows.Forms.MessageBox]::Show("Item to copy was not found: '$linkTarget'", 
                                                ($host.ui.RawUI.WindowTitle), 
                                                [System.Windows.Forms.MessageBoxButtons]::Ok, 
                                                [System.Windows.Forms.MessageBoxIcon]::Error)
    }

} else {
    Write-Host [i] Copying target item to destination folder...
    $shellApp.NameSpace("$destinationDir").CopyHere("$linkTarget")
    Write-Host [i] Copy operation completed.
    [Int32] $exitCode = 0
    if($isVisible) {Timeout /T 5}
}

Exit($exitCode)

Example registry script to run the Powershell script (not using WScript.exe, so not totally hidden) through explorer's context-menu for .lnk files:

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\lnkfile\shell\LinkTargetCopier]
@="Copy link target into this directory"
"position"="top"
"icon"="C:\\Windows\\System32\\OpenWith.exe,0"

[HKEY_CLASSES_ROOT\lnkfile\shell\LinkTargetCopier\command]
@="PowerShell.exe -ExecutionPolicy Bypass -WindowStyle Hidden -File \"C:\\Windows\\LinkTargetCopier.ps1\" -LinkFile \"%1\""

UPDATE

VBS script to run the LinkTargetCopier.ps1 hidden:

RunProcessHidden.vbs

' This script runs hidden the specified program (.exe, .bat, .ps1, etc).

If WScript.Arguments.Count < 1 Then
    Call MsgBox("In order to use this script, you must pass a command-line argument " & _ 
                "containing the path to the program to run." & vbCrLf & vbCrLf & _
                "You can run executable files such as .exe, .bat, .ps1, etc." & vbCrLf & vbCrLf & _
                "Example:" & vbCrLf & _
                "" & Wscript.ScriptName & "" & " Program.exe " & "Arguments", 0+64, Wscript.ScriptName)
    WScript.Quit(1)
End if

' https://mcmap.net/q/472841/-vbscript-how-to-join-wscript-arguments
ReDim arr(WScript.Arguments.Count -1 )
For i = 1 To (WScript.Arguments.Count - 1)
    if Instr(WScript.Arguments(i), " ")>0 Then ' If argument contains white spaces.
     ' Add the argument with double quotes at start and end of the string.
     arr(i) = """"+WScript.Arguments(i)+""""
    else ' Add the argument without double quotes.
     arr(i) = ""+WScript.Arguments(i)+""
    End if
Next

argumentsString = Join(arr, " ")
'WScript.echo """" & WScript.Arguments(0) & """ " & argumentsString & ""

' https://ss64.com/vb/run.html
CreateObject("Wscript.Shell").Run """" & WScript.Arguments(0) & """ " & argumentsString & "", 0, False
WScript.Quit(0)

Corresponding Registry Script to run LinkTargetCopier.ps1 hidden for .lnk files via Explorer's context-menu with WScript.exe and RunProcessHidden.vbs:

Windows Registry Editor Version 5.00

[HKEY_CLASSES_ROOT\lnkfile\shell\CopyLinkTarget]
@="Copy link target here"
"icon"="C:\\Windows\\system32\\OpenWith.exe,0"
"position"="top"

[HKEY_CLASSES_ROOT\lnkfile\shell\CopyLinkTarget\command]
@="WScript.exe \"C:\\Windows\\RunProcessHidden.vbs\" PowerShell.exe -ExecutionPolicy Bypass -File \"C:\\Windows\\LinkTargetCopier.ps1\" -LinkFile \"%1\""
Chrysolite answered 8/12, 2022 at 20:20 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.