ElektroStudios
ElektroStudios

Reputation: 20464

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

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://stackoverflow.com/a/33760315/1248295

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://stackoverflow.com/a/33760315/1248295

# - "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

Upvotes: 4

Views: 1370

Answers (2)

ElektroStudios
ElektroStudios

Reputation: 20464

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://stackoverflow.com/a/33760315/1248295

# - "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://stackoverflow.com/questions/74728834/copy-a-file-using-powershell-when-the-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://stackoverflow.com/a/22769109/1248295
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://stackoverflow.com/a/62279888/1248295

[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://stackoverflow.com/a/58847149/1248295
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\""

Upvotes: 0

zett42
zett42

Reputation: 27756

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'

Upvotes: 5

Related Questions