Tool Time - Clipboard History
A reminder that all previous articles can be found in the online archive. Premium subscribers have full access.
As I've worked with PowerShell over the years, I've realized that the reason for writing PowerShell code generally falls into two camps. One outcome is to achieve a desired result. For example, you may write PowerShell code to provision a new user account in Active Directory. I tend to think this is the purpose of many PowerShell scripts, although functions and cmdlets can fall into this category as well. Something as simple as Start-Service
is a cmdlet that achieves a desired result.
The other category is PowerShell code as a tool. Perhaps another way to consider this is as a utility like nslookup
or ipconfig
. I think an argument could be made that Get-
commands fall into this group. Another good example of a PowerShell tool is your prompt
function. Since I spend a lot of time working from a PowerShell prompt, I have written a number of PowerShell tools to help me work more efficiently. Recently, I've added a few new tools to my toolbox that I'd like to share with you. If nothing else, you might pick up a scripting tip or trick that you can use in your projects.
Clipboard History
Windows systems have a clipboard history feature that I keep forget to take advantage of. Press Windows+V
to see a list of items you've copied to the clipboard. Although, you might need to turn it on.
Once you've added some items, you can see them in the clipboard history.
You can click on an item and Windows will insert it the cursor location of the active application. I wanted to find a way to access this clipboard history from PowerShell. The Get-Clipboard
cmdlet only retrieves the last copied content. I wanted to see the entire history, which is limited to 25 items.
Windows Runtime and WinMetadata
To be honest, I had no idea of where to even begin. I assumed I would need to use .NET classes and methods, but I'm not a .NET developer. I decided to take advantage of AI and asked CoPilot to write some PowerShell code to achieve my goal. The code was complicated and didn't work in PowerShell 7, which is my primary environment. I kept digging and searching. I read a lot of stuff from StackOverflow. Eventually, I started to get the picture.
The clipboard history feature required use of the WindowsRuntime
assembly. Typically, this command should get the job done.
Add-Type -AssemblyName System.Runtime.WindowsRuntime
In PowerShell 7, this looks like it works. But it actually doesn't. During code development, run the command like this:
Add-Type -AssemblyName System.Runtime.WindowsRuntime -PassThru
Now, I see that this assembly isn't supported.
A little more research confirmed that this class is not supported in PowerShell 7. It requires the full .NET Framework. Remember, PowerShell 7 is running on .NET Core. Here's a helper function, i.e. another tool, that will tell you what .NET version you are using in a given PowerShell session.
Function Get-RuntimeInformation {
[cmdletbinding()]
[OutputType('PSRunTimeInformation')]
[alias('rti')]
Param()
[PSCustomObject]@{
PSTypeName = 'PSRuntimeInformation'
PSVersion = $PSVersionTable.PSVersion
Framework = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
FrameworkVersion = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription.Replace('.NET', '').Trim()
OSVersion = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription
Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
Computername = [Environment]::MachineName
}
}
Here's an example from PowerShell 7
PS C:\> Get-RuntimeInformation
PSVersion : 7.5.0-preview.4
Framework : .NET 9.0.0-preview.6.24327.7
FrameworkVersion : 9.0.0-preview.6.24327.7
OSVersion : Microsoft Windows 10.0.26100
Architecture : X64
Computername : JEFFDESK
And from Windows PowerShell
PS C:\> get-RuntimeInformation
PSVersion : 5.1.26100.2152
Framework : .NET Framework 4.8.9277.0
FrameworkVersion : Framework 4.8.9277.0
OSVersion : Microsoft Windows 10.0.26100
Architecture : X64
Computername : JEFFDESK
Now, I know that I need to use Windows PowerShell 5.1.
The other piece of information I learned I would need is the use of WinMetadata assemblies. I believe these are used in UWP (Universal Windows Platform) applications. These are files with a .winmd
extension. You can find them in the C:\Windows\System32\WinMetadata
folder. Here's an example of what you might see.
PS C:\> dir C:\windows\system32\WinMetadata\*.winmd
Directory: C:\windows\system32\WinMetadata
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 9/30/2024 11:08 PM 58888 Windows.AI.winmd
-a---- 10/15/2024 8:43 AM 893040 Windows.ApplicationModel.winmd
...
My sample code indicated I needed to use the Windows.ApplicationModel assembly. However, you can't just load these assemblies with Add-Type
, even in Windows PowerShell.
My research dug up this bit of code.
$null = [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType = WindowsRuntime]
If I try this without sending it to $null
, I can see that it does something.
I have never seen syntax like this, and even though it does something, I didn't know what that was. Years ago, I would have had to bug one of my .NET developer friends to explain it to me. But now, I have CoPilot. I asked it to explain the syntax to me.
Explain what this line of code is doing: [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType = WindowsRuntime]
This line of code is actually a metadata attribute applied to a class or method in a Windows Runtime (WinRT) environment, like a UWP (Universal Windows Platform) app. Here’s the breakdown:
Windows.ApplicationModel.DataTransfer.Clipboard
: This refers to the clipboard API provided by the Windows Runtime. It's used to interact with the system clipboard, enabling apps to cut, copy, and paste data.
Windows.ApplicationModel.DataTransfer
: This is the namespace that contains classes for working with data transfer operations, such as clipboard and drag-and-drop.
ContentType = WindowsRuntime
: This indicates that the content type is specific to the Windows Runtime, which is a platform for Windows apps that provides a common API layer.
Effectively, this attribute specifies that the clipboard functionality from the Windows.ApplicationModel.DataTransfer
namespace should be used, and that the content type is suitable for the Windows Runtime.
I'll be honest, there's no way I would have figured this out on my own. But I know enough about .NET to understand this explanation and began to visualize what the code snippets were doing. I also have enough experience to turn them into PowerShell code.
Asynchronous Operations
Now it gets really interesting.I knew from my research I need to use [Windows.ApplicationModel.DataTransfer.Clipboard]
to access the clipboard history.
This screen shot confirms what I have in my suggested code snippets.
PS C:\> [Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync
OverloadDefinitions
-------------------
static Windows.Foundation.IAsyncOperation[Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult]
GetHistoryItemsAsync()
The output is going to be an asynchronous operation.
I can try to run this.
$op = [Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()
But, I'm not getting the usual output that I can work with.
PS C:\> $op = [Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()
PS C:\> $op
System.__ComObject
PS C:\> $op | Get-Member
TypeName: System.__ComObject
Name MemberType Definition
---- ---------- ----------
CreateObjRef Method System.Runtime.Remoting.ObjRef CreateObjRef(type requestedType)
Equals Method bool Equals(System.Object obj)
GetHashCode Method int GetHashCode()
GetLifetimeService Method System.Object GetLifetimeService()
GetType Method type GetType()
InitializeLifetimeService Method System.Object InitializeLifetimeService()
ToString Method string ToString()
The operation results are coming asynchronously, which means I need to capture them asynchronously. Fortunately, my research had shown me code for a function to get asynchronous results.
function Await($WinRtTask,$ResultType) {
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
$netTask.Wait(-1)
$netTask.Result
}
I can get results from the asynchronous operation like this.
$result = Await $op ([Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult])
Getting the actual content still requires a little digging.
PS C:\> $result.items[0].content
AvailableFormats Properties RequestedOperation
---------------- ---------- ------------------
System.__ComObject {} None
PS C:\> $result.items[0].content.AvailableFormats
Locale
Text
HTML Format
The Content
property has a GetTextAsync()
method. I can use the same Await
function to get the text.
PS C:\> await $result.items[0].Content.GetTextAsync() ([string])
True
$op = [Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()
This means I can use a ForEach
loop to process the results.
$cbh = foreach ($item in $result.items) {
Await $item.Content.GetTextAsync() ([String])
}
Get-ClipboardHistory
Once I verified the different code snippets worked, I was able to write a more complete Windows PowerShell function. I restructured the Await
helper function to be a more traditional PowerShell function.
Function Get-ClipBoardHistory {
[cmdletbinding()]
[OutputType('PSClipboardHistory')]
[alias('cbh')]
Param(
[Parameter(Position = 0)]
[ValidateRange(0, 24)]
[int32]$ID = 0,
[switch]$All
)
function Await {
[CmdletBinding()]
Param([object]$WinRtTask, [Type]$ResultType)
$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() |
Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]
$asTask = $asTaskGeneric.MakeGenericMethod($ResultType)
$netTask = $asTask.Invoke($null, @($WinRtTask))
if ($netTask.Exception) {
Write-Debug $netTask.Exception.InnerException
}
else {
[void]($netTask.Wait(-1))
$netTask.Result
}
}
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType = WindowsRuntime]
$op = [Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()
$result = Await $op ([Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult])
$i = 0
$out = foreach ($item in $result.items) {
[PSCustomObject]@{
PSTypeName = 'PSClipboardHistory'
Id = $i
Time = $item.Timestamp.DateTime
Text = Await $item.Content.GetTextAsync() "String"
}
$i++
} #foreach
If ($all) {
$out
}
else {
$out[$id]
}
}
The function will get the last copied item by default.
PS C:\> Get-ClipboardHistory
Id Time Text
-- ---- ----
0 10/16/2024 11:47:49 AM The function will get the last copied item by default.
Or I can specify everything.
There will never be more than 25 items. My output adds an index number so that I can set clipboard content using another function.
Function Copy-ClipBoardHistory {
[cmdletbinding()]
Param(
[Parameter(Position = 0, ValueFromPipeline, Mandatory)]
[object]$InputObject
)
Process {
$InputObject.Text | Set-Clipboard
}
}
This is simple function that takes the output from Get-ClipboardHistory
and sets the text to the clipboard.
PS C:\> $cbh = Get-ClipboardHistory -all
PS C:\> $cbh[5] | Copy-ClipBoardHistory
I now have a set of clipboard tools I can use in Windows PowerShell.,
PowerShell 7
But what about PowerShell 7? The code is incompatible. Yes, it is. I need to use Windows PowerShell. Which I can do from PowerShell 7!
powershell -nologo -noProfile -command { . c:\scripts\Get-ClipboardHistory.ps1 ; cbh -all}
This will spin up a temporary Windows PowerShell session, dot-source the script, and run the function. I can even pass parameters.
However, I don't want to rely on hard-coded paths and certainly don't want to type this command every time. So I wrote a wrapper function and put it in the same file as Get-ClipboardHistory
.
if ($IsCoreCLR) {
#Get the path of this script
$source = $MyInvocation.MyCommand.Source
Function Get-PwshClipBoardHistory {
[cmdletbinding()]
[OutputType('PSClipboardHistory')]
Param(
[Parameter(Position = 0)]
[ValidateRange(0, 24)]
[int32]$ID = 0,
[switch]$All,
[String]$ScriptSource = $Source
)
if ($All) {
$params = "-All"
}
else {
$params = "-ID $ID"
}
$cmd = ". $ScriptSource ; Get-ClipboardHistory $params"
Write-Verbose "Launching '$cmd'"
powershell -nologo -noProfile -command $cmd
} #close Get-PwshClipBoardHistory
}
else {
#define the Windows PowerShell function
}
#Define the copy function which works in both versions of PowerShell.
...
This function passes the same parameters to the Windows PowerShell function. I
PS C:\> Get-PwshClipBoardHistory -id 15 -Verbose
VERBOSE: Launching '. C:\scripts\get-ClipboardHistory.ps1 ; Get-ClipboardHistory -ID 15'
Id Time Text
-- ---- ----
15 10/16/2024 11:44:39 AM https://www.linkedin.com/feed/
Summary
This is one of the more complicated functions I've had to develop and sometimes it still feels like magic. But now I have a set of clipboard tools that will work in Windows PowerShell and PowerShell 7, at least on a Windows platform, which I also handle at the top of my script file.
If ($IsLinux -OR $IsMacOS) {
Write-Warning 'This script requires a Windows platform to run'
#bail out
Return
}
If you have questions, don't be shy about asking. You probably aren't the only one.
Nice! Just tiny remark, Get-ClipboardHistory throws an error when the clipboard is empty. Wouldn't NULL be more suitable? Or maybe an empty array?