ClipboardText.psm1

<#
 
IMPORTANT: THIS MODULE MUST REMAIN PSv2-COMPATIBLE.
 
#>


# Module-wide defaults.

# !! PSv2: We do not even activate the check for accessing nonexistent variables, because
# !! of a pitfall where parameter variables belonging to a parameter set
# !! other than the one selected by a given invocation are considered undefined.
if ($PSVersionTable.PSVersion.Major -gt 2) {
  Set-StrictMode -Version 1
}

#region == ALIASES
Set-Alias scbt Set-ClipboardText
Set-Alias gcbt Get-ClipboardText
#endregion

#region == Exported functions

function Get-ClipboardText {
<#
.SYNOPSIS
Gets text from the clipboard.
 
.DESCRIPTION
Retrieves text from the system clipboard as an arry of lines (by default)
or as-is (with -Raw).
 
If the clipboard is empty or contains no text, $null is returned.
 
LINUX CAVEAT: The xclip utility must be installed; on Debian-based platforms
              such as Ubuntu, install it with: sudo apt install xclip
 
.PARAMETER Raw
Output the retrieved text as-is, even if it spans multiple lines.
By default, if the retrieved text is a multi-line string, each line is
output individually.
 
.NOTES
This function is a "polyfill" to make up for the lack of built-in clipboard
support in Windows Powershell v5.0- and in PowerShell Core as of v6.1,
albeit only with respect to text.
 
In Windows PowerShell v5+, you can use the built-in Get-Clipboard cmdlet
instead (which this function invokes, if available).
 
In earlier versions, a helper type is compiled on demand that uses the
Windows API. Note that this means the first invocation of this function
in a given session will be noticeably slower, due to the on-demand compilation.
 
.EXAMPLE
Get-ClipboardText | ForEach-Object { $i=0 } { '#{0}: {1}' -f (++$i), $_ }
 
Retrieves text from the clipboard and sends its lines individually through
the pipeline, using a ForEach-Object command to prefix each line with its
line number.
 
.EXAMPLE
Get-ClipboardText -Raw > out.txt
 
Retrieves text from the clipboard as-is and saves it to file out.txt
(with a newline appended).
#>

  
  [CmdletBinding()]
  [OutputType([string])]
  param(
    [switch] $Raw
  )

  $rawText = $lines = $null
  # *Windows PowerShell* v5+ in *STA* COM threading mode (which is the default, but it can be started with -MTA)
  if ((test-WindowsPowerShell) -and $PSVersionTable.PSVersion.Major -ge 5 -and 'STA' -eq [threading.thread]::CurrentThread.ApartmentState.ToString()) { 

    Write-Verbose "Windows (PSv5+ in STA mode): deferring to Get-Clipboard"
    if ($Raw) {
      $rawText = Get-Clipboard -Format Text -Raw
    } else {
      $lines = Get-Clipboard -Format Text
    }

  } else {  # Windows PowerShell v4- and/or in MTA threading mode, PowerShell *Core* on any supported platform.

    # No native PS support for writing to the clipboard or native support not available due to MTA mode -> external utilities
    # must be used.
    # (Note: Attempts to use [System.Windows.Forms] proved to be brittle in MTA mode, causing intermittent failures.)
    # Since PS automatically splits external-program output into individual
    # lines and trailing empty lines can get lost in the process, we
    # must, unfortunately, send the text to a temporary *file* and read
    # that.

    $isWin = $env:OS -eq 'Windows_NT' # Note: $IsWindows is only available in PS *Core*.

    if ($isWin) {

      Write-Verbose "Windows: using WinAPI via helper type"

      # Note: Originally we used a WSH-based solution a la http://stackoverflow.com/a/15747067/45375,
      # but WSH may be blocked on some systems for security reasons.
      add-WinApiHelperType

      $rawText = [net.same2u.util.Clipboard]::GetText()

    } else {

      $tempFile = [io.path]::GetTempFileName()

      try {

        # Note: For security reasons, we want to make sure it is the actual standard
        # shell we're invoking on each platform, so we use its full path.
        # Similarly, for clipboard utilities that are standard on a given platform,
        # we use their full paths.
        # Mocking executables invoked by their full paths isn't directly supported
        # in Pester, so we use helper function invoke-External, which *can* be mocked.
        
        if ($IsMacOS) {

          Write-Verbose "macOS: using pbpaste"

          invoke-External /bin/sh -c "/usr/bin/pbpaste > '$tempFile'"

        } else { # $IsLinux

          Write-Verbose "Linux: using xclip"

          # Note: Requires xclip, which is not installed by default on most Linux distros
          # and works with freedesktop.org-compliant, X11 desktops.
          # Note: Since xclip is not an in-box utility, we make no assumptions
          # about its specific location and rely on it to be in $env:PATH.
          invoke-External /bin/sh -c "xclip -selection clipboard -out > '$tempFile'"
          # Check for the specific exit code that indicates that `xclip` wasn't found and provide an installation hint.
          if ($LASTEXITCODE -eq 127) { new-StatementTerminatingError "xclip is not installed; please install it via your platform's package manager; e.g., on Debian-based distros such as Ubuntu: sudo apt install xclip" }

        }
        
        if ($LASTEXITCODE) { new-StatementTerminatingError "Invoking the native clipboard utility failed unexpectedly." }

        # Read the contents of the temp. file into a string variable.
        # Temp. file is UTF8, which is the default encoding
        $rawText = [IO.File]::ReadAllText($tempFile)

      } finally {
        Remove-Item $tempFile
      }
    } # -not $isWin
  }

  # Output the retrieved text
  if ($Raw) {  # as-is (potentially multi-line)
    $result = $rawText
  } else {     # as an array of lines (as the PsWinV5+ Get-Clipboard cmdlet does)
    if ($null -eq $lines) {
      # Note: This returns [string[]] rather than [object[]], but that should be fine.
      $lines = $rawText -split '\r?\n'
    }
    $result = $lines
  }

  # If the effective result is the *empty string* [wrapped in a single-element array], we output
  # $null, because that's what the PsWinV5+ Get-Clipboard cmdlet does.
  if (-not $result) {
    # !! To be consistent with Get-Clipboard, we output $null even in the absence of -Raw,
    # !! even though you could argue that *nothing* should be output (i.e., implicitly, the "arry-valued null",
    # !! [System.Management.Automation.Internal.AutomationNull]::Value)
    # !! so that trying to *enumerate* the result sends nothing through the pipeline.
    # !! (A similar, but opposite inconsistency is that Get-Content with a zero-byte file outputs the "array-valued null"
    # !! both with and without -Raw).
    $null
  } else {
    $result
  }

}
  
function Set-ClipboardText {
<#
.SYNOPSIS
Copies text to the clipboard.
 
.DESCRIPTION
Copies a text representation of the input to the system clipboard.
 
Input can be provided via the pipeline or via the -InputObject parameter.
 
If you provide no input, the empty string, or $null, the clipboard is
effectively cleared.
 
Non-text input is formatted the same way as it would print to the console,
which means that the console/terminal window's [buffer] width determines
the output line width, which may result in truncated data (indicated with
"...").
To avoid that, you can increase the max. line width with -Width, but see
the caveats in the parameter description.
 
LINUX CAVEAT: The xclip utility must be installed; on Debian-based platforms
              such as Ubuntu, install it with: sudo apt install xclip
 
.PARAMETER Width
For non-text input, determines the maximum output-line length.
The default is Out-String's default, which is the current console/terminal
window's [buffer] width.
 
Be careful with high values and avoid [int]::MaxValue, however, because in
the case of (implicit) Format-Table output each output line is padded to
that very width, which can require a lot of memory.
 
.PARAMETER PassThru
In addition to copying the resulting string representation of the input to
the clipboard, also outputs it, as single string.
 
.NOTES
This function is a "polyfill" to make up for the lack of built-in clipboard
support in Windows Powershell v5.0- and in PowerShell Core as of v6.1,
albeit only with respect to text.
In Windows PowerShell v5.1+, you can use the built-in Set-Clipboard cmdlet
instead (which this function invokes, if available).
 
.EXAMPLE
Set-ClipboardText "Text to copy"
 
Copies the specified text to the clipboard.
 
.EXAMPLE
Get-ChildItem -File -Name | Set-ClipboardText
 
Copies the names of all files the current directory to the clipboard.
 
.EXAMPLE
Get-ChildItem | Set-ClipboardText -Width 500
 
Copies the text representations of the output from Get-ChildItem to the
clipboard, ensuring that output lines are 500 characters wide.
#>

  
  [CmdletBinding(DefaultParameterSetName='Default')] # !! PSv2 doesn't support PositionalBinding=$False
  [OutputType([string], ParameterSetName='PassThru')]
  param(
      [Parameter(Position=0, ValueFromPipeline = $True)] # Note: The built-in PsWinV5.0+ Set-Clipboard cmdlet does NOT have mandatory input, in which case the clipbard is effectively *cleared*.
      [AllowNull()] # Note: The built-in PsWinV5.0+ Set-Clipboard cmdlet allows $null too.
      $InputObject
      ,
      [int] $Width # max. output-line width for non-string input
      ,
      [Parameter(ParameterSetName='PassThru')]
      [switch] $PassThru
  )

  begin {
    # Initialize an array to collect all input objects in.
    # !! Incredibly, in PSv2 using either System.Collections.Generic.List[object] or
    # !! System.Collections.ArrayList ultimately results in different ... | Out-String
    # !! output, with the group header ('Directory:') for input `GetItem / | Out-String`
    # !! inexplicably missing - even .ToArray() conversion or an [object[]] cast
    # !! before piping to Out-String doesn't help.
    # !! Given that we don't expect large collections to be sent to the clipboard,
    # !! we make do with inefficiently "growing" an *array* ([object[]]), i.e.
    # !! cloning the old array for each input object.
    $inputObjs = @()
  }

  process {
    # Collect the input objects.
    $inputObjs += $InputObject
  }
  
  end {

    # * The input as a whole is converted to a a single string with
    # Out-String, which formats objects the same way you would see on the
    # console.
    # * Since Out-String invariably appends a trailing newline, we must remove it.
    # (The PS Core v6 -NoNewline switch is NOT an option, as it also doesn't
    # place newlines *between* objects.)
    $widthParamIfAny = if ($PSBoundParameters.ContainsKey('Width')) { @{ Width = $Width } } else { @{} }
    $allText = ($inputObjs | Out-String @widthParamIfAny) -replace '\r?\n\z'

    # *Windows PowerShell* v5+ in *STA* COM threading mode (which is the default, but it can be started with -MTA)
    if ((test-WindowsPowerShell) -and $PSVersionTable.PSVersion.Major -ge 5 -and 'STA' -eq [threading.thread]::CurrentThread.ApartmentState.ToString()) { 
        
      # !! As of PsWinV5.1, `Set-Clipboard ''` reports a spurious error (but still manages to effectively) clear the clipboard.
      # !! By contrast, using `Set-Clipboard $null` succeeds.
      Set-Clipboard -Value ($allText, $null)[$allText.Length -eq 0]
      
    } else { # Windows PowerShell v4- and/or in MTA threading mode, PowerShell *Core* on any supported platform.
      
      # No native PS support for writing to the clipboard or native support not available due to MTA mode ->
      # external utilities must be used.
      # (Note: Attempts to use [System.Windows.Forms] proved to be brittle in MTA mode, causing intermittent failures.)

      $isWin = $env:OS -eq 'Windows_NT' # Note: $IsWindows is only available in PS *Core*.

      # To prevent adding a trailing \n, which PS inevitably adds when sending
      # a string through the pipeline to an external command, use a temp. file,
      # whose content can be provided via native input redirection (<)
      $tmpFile = [io.path]::GetTempFileName()

      if ($isWin) {
        # The clip.exe utility requires *BOM-less* UTF16-LE for full Unicode support.
        [IO.File]::WriteAllText($tmpFile, $allText, (New-Object System.Text.UnicodeEncoding $False, $False))
      } else { # $IsUnix -> use BOM-less UTF8
        # PowerShell's UTF8 encoding invariably creates a file WITH BOM
        # so we use the .NET Framework, whose default is BOM-*less* UTF8.
        [IO.File]::WriteAllText($tmpFile, $allText)
      }
      
      # Feed the contents of the temporary file via stdin to the
      # platform-appropriate clipboard utility.
      try {

        # Note: For security reasons, we want to make sure it is the actual standard
        # shell we're invoking on each platform, so we use its full path.
        # Similarly, for clipboard utilities that are standard on a given platform,
        # we use their full paths.
        # Mocking executables invoked by their full paths isn't directly supported
        # in Pester, so we use helper function invoke-External, which *can* be mocked.
  
        if ($isWin) {

          Write-Verbose "Windows: using clip.exe"

          # !! Temporary switch to the system drive (a drive guaranteed to be local) so as to
          # !! prevent cmd.exe from issuing a warning if a UNC path happens to be the current location
          # !! - see https://github.com/mklement0/ClipboardText/issues/4
          Push-Location -LiteralPath $env:SystemRoot
            invoke-External "$env:SystemRoot\System32\cmd.exe" /c "$env:SystemRoot\System32\clip.exe" '<' $tmpFile
          Pop-Location

        } elseif ($IsMacOS) {

          Write-Verbose "macOS: using pbcopy"

          invoke-External /bin/sh -c "/usr/bin/pbcopy < '$tmpFile'"

        } else { # $IsLinux

          Write-Verbose "Linux: using xclip"
          # Note: Since xclip is not an in-box utility, we make no assumptions
          # about its specific location and rely on it to be in $env:PATH.
          # !! >&- (i.e., closing stdout) is necessary, because xclip hangs if you try to redirect its - nonexistent output with `-in`, which also happens impliclity via `$null = ...` in the context of Pester tests.
          invoke-External /bin/sh -c "xclip -selection clipboard -in < '$tmpFile' >&-"

          # Check for the specific exit code that indicates that `xclip` wasn't found and provide an installation hint.
          if ($LASTEXITCODE -eq 127) { new-StatementTerminatingError "xclip is not installed; please install it via your platform's package manager; e.g., on Debian-based distros such as Ubuntu: sudo apt install xclip" }

        }
        
        if ($LASTEXITCODE) { new-StatementTerminatingError "Invoking the platform-specific clipboard utility failed unexpectedly." }

      } finally {
        Pop-Location # Restore the previously current location.
        Remove-Item $tmpFile
      }

    }

    if ($PassThru) {
      $allText
    }

  }

}

#endregion

#region == Private helper functions
  
# Throw a statement-terminating error (instantly exits the calling function and its enclosing statement).
function new-StatementTerminatingError([string] $Message, [System.Management.Automation.ErrorCategory] $Category = 'InvalidOperation') {
    $PSCmdlet.ThrowTerminatingError((New-Object System.Management.Automation.ErrorRecord `
      $Message,
      $null, # a custom error ID (string)
      $Category, # the PS error category - do NOT use NotSpecified - see below.
      $null # the target object (what object the error relates to)
    )) 
}

# Determine if we're runnning in Windows PowerShell.
function test-WindowsPowerShell {
  # !! $IsCoreCLR is not available in Windows PowerShell and, if
  # !! Set-StrictMode is set, trying to access it would fail.
  $null, 'Desktop' -contains $PSVersionTable.PSEdition 
}

# Helper function for invoking an external utility (executable).
# The raison d'être for this function is so that calls to utilities called
# with their *full paths* can be mocked in Pester.
function invoke-External {
  param(
    [Parameter(Mandatory=$true)]
    [string] $LiteralPath,
    [Parameter(ValueFromRemainingArguments=$true)]
    $PassThruArgs
  )
  & $LiteralPath $PassThruArgs
}


# Adds helper type [net.same2u.util.Clipboard] for clipboard access via the
# Windows API.
# Note: It is fine to blindly call this function repeatedly - after the initial
# performance hit due to compilation, subsequent invocations are very fast.
function add-WinApiHelperType {
  Add-Type -Name Clipboard -Namespace net.same2u.util -MemberDefinition @'
  [DllImport("user32.dll", SetLastError=true)]
  static extern bool OpenClipboard(IntPtr hWndNewOwner);
  [DllImport("user32.dll", SetLastError = true)]
  static extern IntPtr GetClipboardData(uint uFormat);
  [DllImport("user32.dll", SetLastError=true)]
  static extern bool CloseClipboard();
   
  public static string GetText() {
    string txt = null;
    if (!OpenClipboard(IntPtr.Zero)) { throw new Exception("Failed to open clipboard."); }
    IntPtr handle = GetClipboardData(13); // CF_UnicodeText
    if (handle != IntPtr.Zero) { // if no handle is returned, assume that no text was on the clipboard.
      txt = Marshal.PtrToStringAuto(handle);
    }
    if (!CloseClipboard()) { throw new Exception("Failed to close clipboard."); }
    return txt;
  }
'@

}

#endregion