Working in .NET 8 on Windows (net8.0-windows), I'd like to put several files on the clipboard but only provide the actual data once the user pastes.
This seems straightforward: I subclass DataObject
and override the GetData
function, handling the two relevant formats ("FileGroupDescriptorW" and "FileContents"):
private class MyDataObject : DataObject
{
public override object GetData(string format, bool autoConvert)
{
if (string.Compare(format, CFSTR_FILEDESCRIPTORW, StringComparison.OrdinalIgnoreCase) == 0)
{
MemoryStream ms = // Create a FileGroupDescriptorW, get the bytes and create a memory stream
base.SetData(CFSTR_FILEDESCRIPTORW, ms);
}
else if (string.Compare(format, CFSTR_FILECONTENTS, StringComparison.OrdinalIgnoreCase) == 0)
{
// pseudo-code, I have a stream implementation that will provide the file bytes
base.SetData(CFSTR_FILECONTENTS, new MyVirtualFileStream());
}
return base.GetData(format, autoConvert);
}
And I simply drop my data object on the clipboard:
MyDataObject appDataObject = new();
appDataObject.SetData(CFSTR_FILEDESCRIPTORW, null);
appDataObject.SetData(CFSTR_FILECONTENTS, null);
System.Windows.Forms.Clipboard.SetDataObject(appDataObject);
So far, so good; and this works great if I have a single file. I paste in Windows Explorer and my file is pasted successfully.
But what if I have multiple files? i.e. FileGroupDescriptorW
has a count greater than 1. There is no way to get the index that's requested in GetData
or any of its overloads.
In the COM equivalent IDataObject
, FORMATETC has the lindex
property that gives this information, but it doesn't seem there is anything equivalent on .NET. Is this truly not possible in managed code?
I'm looking for a clear example of how to implement this.
ChatGPT suggested simply storing an index and incrementing it manually, but as far as I can tell there is nothing that suggest Windows will request the file contents in sequential order.
ChatGPT further suggested dropping down to COM to achieve this; which I'm fine with if it works. But even after implementing a barebones System.Runtime.InteropServices.ComTypes.IDataObject
and trying to assign it to the clipboard with either SetDataObject
or OleSetClipboard
; I immediately get an access violation (without even hitting any of the code in my skeleton class - makes me think I'm doing something fundamentally wrong).
Can anyone suggest a path forward on how to achieve this?
The .NET-provided DataObjects (WPF & Winforms, and BTW they're not the same wich is kinda dumb...) are really designed for .NET, including .NET objects, etc. they are not designed for general purpose clipboard or copy-paste operations.
Here is some sample C# code that allows you to add on-demand streamable file descriptors to a custom-made native IDataObject, that can itself be added to the clipboard (or to something else). It supports paste from Explorer, from Office apps (Outloook, Word), etc.:
using System;
using System.Collections.Generic;
using System.Drawing; // just used for SIZE and POINT struct that could be rewritten if Winforms is not desired
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Windows.Forms; // just used for MessageBox in caller's code
namespace OnDemandDataObject;
internal class Program
{
[STAThread] // calling thread must be STA
static void Main()
{
using var da = new DataObject();
da.SetOnDemandFiles([
FileDescriptor.FromFile(@"d:\temp\first.pdf"),
FileDescriptor.FromFile(@"d:\temp\second.txt")
// etc.
]);
da.SetToClipboard();
// runs message pump in this console app sample
MessageBox.Show("Press ok to remove files from the clipboard");
}
}
public class FileDescriptor()
{
// this is what's called on-demand when a stream is asked for reading (on paste action)
public virtual Func<Stream>? GetStream { get; set; }
public virtual FILEDESCRIPTOR Descriptor { get; set; }
// utility to create a descriptor from a file
// but could be adapted for network streams, etc.
public static FileDescriptor FromFile(string filePath, Func<Stream>? getStream = null) { ArgumentNullException.ThrowIfNull(filePath); return FromFile(new FileInfo(filePath), getStream); }
public static FileDescriptor FromFile(FileInfo info, Func<Stream>? getStream = null)
{
ArgumentNullException.ThrowIfNull(info);
var fd = new FileDescriptor();
fd.GetStream ??= () => new FileStream(info.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fd.Descriptor = FILEDESCRIPTOR.FromFileInfo(info);
return fd;
}
}
public class DataObject : DataObject.IDataObject, IDisposable
{
private readonly List<(FORMATETC, STGMEDIUM, bool)> _data = [];
private bool _disposedValue;
// can't use Winforms Clipboard.SetData with a custom IDataObject
public virtual void SetToClipboard()
{
OleInitialize(0);
OleSetClipboard(this);
}
public virtual void SetOnDemandFiles(IReadOnlyList<FileDescriptor> files)
{
ArgumentNullException.ThrowIfNull(files);
if (files.Count == 0)
return;
if (files.Any(d => d.GetStream == null))
throw new ArgumentException(null, nameof(files));
// build CFSTR_FILEDESCRIPTORW
var elementSize = Marshal.SizeOf<FILEDESCRIPTOR>();
var size = Marshal.SizeOf<int>() + elementSize * files.Count;
var fileDescriptorsPtr = Marshal.AllocHGlobal(size);
var current = fileDescriptorsPtr;
Marshal.WriteInt32(current, files.Count);
current += Marshal.SizeOf<int>();
foreach (var file in files)
{
Marshal.StructureToPtr(file.Descriptor, current, false);
current += elementSize;
}
try
{
// set CFSTR_FILEDESCRIPTORW
var fmt = new FORMATETC { dwAspect = DVASPECT.DVASPECT_CONTENT, cfFormat = (short)RegisterClipboardFormat(CFSTR_FILEDESCRIPTORW), lindex = -1, tymed = TYMED.TYMED_HGLOBAL };
var medium = new STGMEDIUM { tymed = fmt.tymed, unionmember = fileDescriptorsPtr };
((IDataObject)this).SetData(ref fmt, ref medium, true);
// set all CFSTR_FILECONTENTS
var format = RegisterClipboardFormat(CFSTR_FILECONTENTS);
fmt = new FORMATETC { dwAspect = DVASPECT.DVASPECT_CONTENT, cfFormat = (short)format, tymed = TYMED.TYMED_ISTREAM };
medium = new STGMEDIUM { tymed = fmt.tymed };
for (var i = 0; i < files.Count; i++)
{
fmt.lindex = i;
var stream = new ReadStream(files[i]);
var unk = Marshal.GetComInterfaceForObject(stream, typeof(IStream));
try
{
medium.unionmember = unk;
medium.pUnkForRelease = stream;
((IDataObject)this).SetData(ref fmt, ref medium, true);
}
finally
{
Marshal.Release(unk);
}
}
}
catch // free only on error
{
Marshal.FreeHGlobal(fileDescriptorsPtr);
throw;
}
}
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
foreach (var data in _data.Where(d => d.Item3))
{
var medium = data.Item2;
ReleaseStgMedium(ref medium);
}
_data.Clear();
}
_disposedValue = true;
}
}
~DataObject() { Dispose(disposing: false); }
public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); }
int IDataObject.GetData(ref FORMATETC pformatetcIn, out STGMEDIUM pmedium)
{
foreach (var data in _data)
{
if (data.Item1.cfFormat == pformatetcIn.cfFormat && data.Item1.lindex == pformatetcIn.lindex)
{
var medium = data.Item2;
return CopyStgMediumOut(ref medium, out pmedium);
}
}
pmedium = new();
return DV_E_FORMATETC;
}
int IDataObject.GetDataHere(ref FORMATETC pformatetcIn, ref STGMEDIUM pmedium)
{
foreach (var data in _data)
{
if (data.Item1.cfFormat == pformatetcIn.cfFormat && data.Item1.lindex == pformatetcIn.lindex)
{
var medium = data.Item2;
medium.pUnkForRelease = 0;
return CopyStgMediumRef(ref medium, ref pmedium);
}
}
return DV_E_FORMATETC;
}
int IDataObject.QueryGetData(ref FORMATETC pformatetc)
{
foreach (var data in _data)
{
if (data.Item1.cfFormat == pformatetcIn.cfFormat && data.Item1.lindex == pformatetcIn.lindex)
return 0;
}
return DV_E_FORMATETC;
}
int IDataObject.SetData(ref FORMATETC pformatetc, ref STGMEDIUM pmedium, bool fRelease)
{
foreach (var data in _data.ToArray())
{
if (data.Item1.cfFormat == pformatetc.cfFormat && data.Item1.lindex == pformatetc.lindex)
{
_data.Remove(data);
}
}
_data.Add((pformatetc, pmedium, fRelease));
return 0;
}
int IDataObject.GetCanonicalFormatEtc(ref FORMATETC pformatectIn, out FORMATETC pformatetcOut) => throw new NotImplementedException();
int IDataObject.EnumFormatEtc(DATADIR dwDirection, out IEnumFORMATETC ppenumFormatEtc) { ppenumFormatEtc = new EnumFORMATETC(this, dwDirection); return 0; }
int IDataObject.DAdvise(ref FORMATETC pformatetc, uint advf, IAdviseSink pAdvSink, out uint dwConnection) => throw new NotImplementedException();
int IDataObject.DUnadvise(uint dwConnection) => throw new NotImplementedException();
int IDataObject.EnumDAdvise(out IEnumSTATDATA ppenumAdvise) => throw new NotImplementedException();
private sealed class EnumFORMATETC(DataObject dataObject, DATADIR direction) : IEnumFORMATETC
{
public int Index { get; set; }
public int Next(int celt, FORMATETC[] rgelt, int[] pceltFetched)
{
if (pceltFetched != null) { pceltFetched[0] = 0; }
if (Index >= dataObject._data.Count)
return 1;
var fetched = 0;
while (fetched < celt && fetched < dataObject._data.Count)
{
rgelt[fetched] = dataObject._data[Index].Item1;
Index++;
fetched++;
}
if (pceltFetched != null) { pceltFetched[0] = fetched; }
return fetched == celt ? 0 : 1;
}
public int Reset() { Index = 0; return 0; }
public void Clone(out IEnumFORMATETC newEnum) => newEnum = new EnumFORMATETC(dataObject, direction);
public int Skip(int celt) => throw new NotImplementedException();
}
private sealed class ReadStream : IStream
{
private readonly FileDescriptor _descriptor;
private readonly Lazy<Stream> _stream;
public ReadStream(FileDescriptor descriptor)
{
_descriptor = descriptor;
_stream = new Lazy<Stream>(() => _descriptor.GetStream!() ?? throw new InvalidOperationException());
}
private Stream Stream => _stream.Value;
// Explorer calls here
void IStream.Read(byte[] pv, int cb, nint pcbRead)
{
var read = Stream.Read(pv, 0, cb);
if (pcbRead != 0) { Marshal.WriteInt32(pcbRead, read); }
}
void IStream.Seek(long dlibMove, int dwOrigin, nint plibNewPosition)
{
var newPos = Stream.Seek(dlibMove, (SeekOrigin)dwOrigin);
if (plibNewPosition != 0) { Marshal.WriteInt64(plibNewPosition, newPos); }
}
public void Stat(out STATSTG pstatstg, int grfStatFlag)
{
const int STGTY_STREAM = 2;
const int STGM_READWRITE = 2;
const int STGM_WRITE = 1;
var stream = Stream;
pstatstg = new STATSTG { type = STGTY_STREAM, cbSize = stream.Length, };
const int STATFLAG_NONAME = 1;
if ((grfStatFlag & STATFLAG_NONAME) == 0) pstatstg.pwcsName = _descriptor.Descriptor.cFileName;
pstatstg.atime = ToFileTime(_descriptor.Descriptor.ftLastAccessTime);
pstatstg.ctime = ToFileTime(_descriptor.Descriptor.ftCreationTime);
pstatstg.mtime = ToFileTime(_descriptor.Descriptor.ftLastWriteTime);
pstatstg.clsid = _descriptor.Descriptor.clsid;
if (stream.CanRead && stream.CanWrite)
{
pstatstg.grfMode |= STGM_READWRITE;
return;
}
if (stream.CanWrite) { pstatstg.grfMode |= STGM_WRITE; }
}
// Office (outlook, word, etc.) calls here
public void CopyTo(IStream pstm, long cb, nint pcbRead, nint pcbWritten)
{
ArgumentNullException.ThrowIfNull(pstm);
var count = 0L;
var bytes = new byte[0x14000]; // 81920 under loh
do
{
var max = (int)Math.Min(cb - count, bytes.Length);
var read = Stream.Read(bytes, 0, max);
if (read == 0)
break;
pstm.Write(bytes, read, 0);
count += read;
if (count == cb)
break;
}
while (true);
if (pcbRead != 0) Marshal.WriteInt64(pcbRead, count);
if (pcbWritten != 0) Marshal.WriteInt64(pcbWritten, count);
pstm.Commit(0); // STGC_DEFAULT
Marshal.FinalReleaseComObject(pstm); // we must do this otherwise Office doesn't like it
}
public void Commit(int grfCommitFlags) => Stream.Flush();
void IStream.Write(byte[] pv, int cb, nint pcbWritten) => throw new NotImplementedException();
void IStream.SetSize(long libNewSize) => throw new NotImplementedException();
void IStream.Revert() => throw new NotImplementedException();
void IStream.LockRegion(long libOffset, long cb, int dwLockType) => throw new NotImplementedException();
void IStream.UnlockRegion(long libOffset, long cb, int dwLockType) => throw new NotImplementedException();
void IStream.Clone(out IStream ppstm) => throw new NotImplementedException();
private static FILETIME ToFileTime(long fileTime) => new() { dwLowDateTime = (int)(fileTime & uint.MaxValue), dwHighDateTime = (int)(fileTime >> 32) };
}
private const int DV_E_FORMATETC = unchecked((int)0x80040064);
private const string CFSTR_FILEDESCRIPTORW = "FileGroupDescriptorW";
private const string CFSTR_FILECONTENTS = "FileContents";
[DllImport("user32", CharSet = CharSet.Unicode)]
private static extern int RegisterClipboardFormat(string format);
[DllImport("ole32")]
private static extern int OleSetClipboard(IDataObject pDataObj);
[DllImport("ole32")]
private static extern int OleInitialize(nint pvReserved);
[DllImport("ole32")]
private static extern void ReleaseStgMedium(ref STGMEDIUM medium);
[DllImport("urlmon", EntryPoint = "CopyStgMedium")]
private static extern int CopyStgMediumOut(ref STGMEDIUM pcstgmedSrc, out STGMEDIUM pstgmedDest);
[DllImport("urlmon", EntryPoint = "CopyStgMedium")]
private static extern int CopyStgMediumRef(ref STGMEDIUM pcstgmedSrc, ref STGMEDIUM pstgmedDest);
[ComImport, Guid("0000010E-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IDataObject // redefined so we don't need to throw
{
[PreserveSig]
int GetData(ref FORMATETC pformatetcIn, out STGMEDIUM pmedium);
[PreserveSig]
int GetDataHere(ref FORMATETC pformatetcIn, ref STGMEDIUM pmedium);
[PreserveSig]
int QueryGetData(ref FORMATETC pformatetc);
[PreserveSig]
int GetCanonicalFormatEtc(ref FORMATETC pformatectIn, out FORMATETC pformatetcOut);
[PreserveSig]
int SetData(ref FORMATETC pformatetc, ref STGMEDIUM pmedium, bool fRelease);
[PreserveSig]
int EnumFormatEtc(DATADIR dwDirection, out IEnumFORMATETC ppenumFormatEtc);
[PreserveSig]
int DAdvise(ref FORMATETC pformatetc, uint advf, IAdviseSink pAdvSink, out uint dwConnection);
[PreserveSig]
int DUnadvise(uint dwConnection);
[PreserveSig]
int EnumDAdvise(out IEnumSTATDATA ppenumAdvise);
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct FILEDESCRIPTOR
{
public FD dwFlags;
public Guid clsid;
public Size sizel;
public Point pointl;
public FileAttributes dwFileAttributes;
public long ftCreationTime;
public long ftLastAccessTime;
public long ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
public override readonly string ToString() => cFileName;
public static FILEDESCRIPTOR FromFileInfo(FileInfo info)
{
ArgumentNullException.ThrowIfNull(info);
var fd = new FILEDESCRIPTOR { dwFlags = FD.FD_UNICODE, cFileName = info.Name };
if (info.Exists)
{
fd.dwFlags |= FD.FD_FILESIZE | FD.FD_ATTRIBUTES;
fd.nFileSizeLow = (uint)(info.Length & uint.MaxValue);
fd.nFileSizeHigh = (uint)(info.Length >> 32);
fd.dwFileAttributes = info.Attributes;
if (IsValidFileTime(info.CreationTimeUtc))
{
fd.ftCreationTime = info.CreationTimeUtc.ToFileTimeUtc();
fd.dwFlags |= FD.FD_CREATETIME;
}
if (IsValidFileTime(info.LastAccessTimeUtc))
{
fd.ftLastAccessTime = info.LastAccessTimeUtc.ToFileTimeUtc();
fd.dwFlags |= FD.FD_ACCESSTIME;
}
if (IsValidFileTime(info.LastWriteTimeUtc))
{
fd.ftLastWriteTime = info.LastWriteTimeUtc.ToFileTimeUtc();
fd.dwFlags |= FD.FD_WRITESTIME;
}
}
const long fileTimeOffset = 504911232000000000; // daysTo1601 * ticksPerDay;
static long ToFileTime(DateTime dt) => (dt.Kind != DateTimeKind.Utc ? dt.ToUniversalTime().Ticks : dt.Ticks) - fileTimeOffset;
static bool IsValidFileTime(DateTime dt) => ToFileTime(dt) >= 0;
return fd;
}
}
[Flags]
public enum FD
{
FD_CLSID = 0x00000001,
FD_SIZEPOINT = 0x00000002,
FD_ATTRIBUTES = 0x00000004,
FD_CREATETIME = 0x00000008,
FD_ACCESSTIME = 0x00000010,
FD_WRITESTIME = 0x00000020,
FD_FILESIZE = 0x00000040,
FD_PROGRESSUI = 0x00004000,
FD_LINKUI = 0x00008000,
FD_UNICODE = unchecked((int)0x80000000),
}