sources/Google.Solutions.Mvvm/Shell/VirtualFileDataObject.cs (358 lines of code) (raw):
//
// Copyright 2024 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Solutions.Common.Interop;
using Google.Solutions.Common.Util;
using Google.Solutions.Mvvm.Controls;
using Google.Solutions.Mvvm.Interop;
using Google.Solutions.Platform.Interop;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Windows.Forms;
using UCOMIDataObject = System.Runtime.InteropServices.ComTypes.IDataObject;
namespace Google.Solutions.Mvvm.Shell
{
/// <summary>
/// IDataObject that allows handling mutiple virtual files in a
/// single operation.
/// </summary>
/// <remarks>
/// DataObject only supports handling a single virtual file
/// (using CFSTR_FILEDESCRIPTORW, CFSTR_FILECONTENTS).
///
/// This class extends DataObject to support multiple files, inspired by
/// https://www.codeproject.com/Articles/23139/Transferring-Virtual-Files-to-Windows-Explorer-in
/// </remarks>
public sealed class VirtualFileDataObject
: DataObject, UCOMIDataObject, VirtualFileDataObject.IDataObjectAsyncCapability, IDisposable
{
private int currentFile = 0;
private bool disposed;
/// <summary>
/// List of streams that were opened during the last operation.
/// </summary>
private readonly List<Stream> openedContentStreams = new List<Stream>();
/// <summary>
/// List of virtual files.
/// </summary>
public IList<Descriptor> Files { get; }
public VirtualFileDataObject(IList<Descriptor> files)
{
this.Files = files;
//
// Enable delayed rendering
//
SetData(ShellDataFormats.CFSTR_FILEDESCRIPTORW, null);
SetData(ShellDataFormats.CFSTR_FILECONTENTS, null);
SetData(ShellDataFormats.CFSTR_PERFORMEDDROPEFFECT, null);
}
/// <summary>
/// Indicates if data extraction should be done asynchronously, i.e.,
/// on a background thrad.
/// </summary>
internal bool IsAsync { get; set; }
/// <summary>
/// Indicates that data extraction is ongoing.
/// </summary>
internal bool IsOperationInProgress { get; private set; }
/// <summary>
/// Raised when asynchronous data extraction is starting.
/// </summary>
public event EventHandler? AsyncOperationStarted;
/// <summary>
/// Raised when asynchronous data extraction ahs ended.
/// </summary>
public event EventHandler<AsyncOperationEventArgs>? AsyncOperationCompleted;
/// <summary>
/// Raised when asynchronous reading or writing failed.
/// </summary>
public event EventHandler<ExceptionEventArgs>? AsyncOperationFailed;
private void ExpectNotDisposed()
{
if (this.disposed)
{
throw new ObjectDisposedException(GetType().Name);
}
}
//----------------------------------------------------------------------
// DataObject/UCOMIDataObject overrides.
//----------------------------------------------------------------------
public override object? GetData(string format, bool autoConvert)
{
if (this.disposed)
{
return null;
}
if (ShellDataFormats.CFSTR_FILEDESCRIPTORW.Equals(format, StringComparison.OrdinalIgnoreCase))
{
//
// Supply group descriptor for all files.
//
base.SetData(
ShellDataFormats.CFSTR_FILEDESCRIPTORW,
Descriptor.ToNativeGroupDescriptorStream(this.Files));
}
else if (ShellDataFormats.CFSTR_FILECONTENTS.Equals(format, StringComparison.OrdinalIgnoreCase))
{
//
// Open the stream. We do that lazily in case the client
// never actally invokes this method.
//
// NB. The base class doesn't dispose the stream. So we need
// to keep track of it and dispose it once the operation
// has completed.
//
if (this.currentFile >= 0 && this.currentFile < this.Files.Count)
{
var contentStream = this.Files[this.currentFile].OpenStream();
this.openedContentStreams.Add(contentStream);
//
// Supply data for the current file.
//
base.SetData(
ShellDataFormats.CFSTR_FILECONTENTS,
contentStream);
}
else
{
//
// Index out of range.
//
base.SetData(
ShellDataFormats.CFSTR_FILECONTENTS,
null);
}
}
return base.GetData(format, autoConvert);
}
/// <summary>
/// Get data to drop.
///
/// NB. This method differs in 2 ways from the base class implementation:
///
/// 1. It extracts the index of the file that is currently
/// being processed. This
/// 2. It adds support for TYMED_ISTREAM.
/// </summary>
void UCOMIDataObject.GetData(ref FORMATETC formatetc, out STGMEDIUM medium)
{
if (this.disposed)
{
medium = default;
medium.tymed = TYMED.TYMED_NULL;
return;
}
if (formatetc.cfFormat ==
(short)DataFormats.GetFormat(ShellDataFormats.CFSTR_FILECONTENTS).Id)
{
//
// Cache the index so that we can use it in GetData(format, autoConvert).
//
this.currentFile = formatetc.lindex;
}
//
// Populate the medium.
//
medium = default;
if (GetTymedUseable(formatetc.tymed))
{
var formatName = DataFormats.GetFormat(formatetc.cfFormat).Name;
this.IsOperationInProgress = true;
try
{
if ((formatetc.tymed & TYMED.TYMED_ISTREAM) != 0 &&
GetDataPresent(formatName) &&
GetData(formatName, false) is Stream dataStream)
{
//
// Return data as a COM IStream.
//
var streamPtr = Marshal.GetIUnknownForObject(new ComStream(dataStream));
medium.tymed = TYMED.TYMED_ISTREAM;
medium.unionmember = streamPtr;
}
else if ((formatetc.tymed & TYMED.TYMED_HGLOBAL) != 0)
{
//
// Return data as an HGLOBAL. The base class can do
// that for us.
//
medium.tymed = TYMED.TYMED_HGLOBAL;
medium.unionmember = NativeMethods.GlobalAlloc(GHND | GMEM_DDESHARE, 1);
if (medium.unionmember == IntPtr.Zero)
{
throw new OutOfMemoryException();
}
try
{
//
// Copy data. This will invoke GetData(format, autoConvert), which
// in turn uses the cached index to provide the right data.
//
((UCOMIDataObject)this).GetDataHere(ref formatetc, ref medium);
}
catch (Exception)
{
NativeMethods.GlobalFree(new HandleRef(medium, medium.unionmember));
medium.unionmember = IntPtr.Zero;
throw;
}
}
else
{
medium.tymed = formatetc.tymed;
((UCOMIDataObject)this).GetDataHere(ref formatetc, ref medium);
}
}
catch (Exception e)
{
if (e is COMException comEx && (
comEx.HResult == (int)HRESULT.DV_E_FORMATETC ||
comEx.HResult == (int)HRESULT.E_FAIL))
{
//
// These can happen during format negotiation and
// aren't worth raising an event for.
//
}
else if (this.IsAsync)
{
this.AsyncOperationFailed?.Invoke(this, new ExceptionEventArgs(e));
}
throw;
}
}
else
{
Marshal.ThrowExceptionForHR((int)HRESULT.DV_E_TYMED);
}
bool GetTymedUseable(TYMED tymed)
{
var allowed = new TYMED[5]
{
TYMED.TYMED_HGLOBAL,
TYMED.TYMED_ISTREAM,
TYMED.TYMED_ENHMF,
TYMED.TYMED_MFPICT,
TYMED.TYMED_GDI
};
for (var i = 0; i < allowed.Length; i++)
{
if ((tymed & allowed[i]) != 0)
{
return true;
}
}
return false;
}
}
//----------------------------------------------------------------------
// IDataObjectAsyncCapability.
//----------------------------------------------------------------------
public void SetAsyncMode([In] int fDoOpAsync)
{
ExpectNotDisposed();
this.IsAsync = fDoOpAsync != VariantBool.False;
}
public void GetAsyncMode([Out] out int pfIsOpAsync)
{
ExpectNotDisposed();
pfIsOpAsync = this.IsAsync ? VariantBool.True : VariantBool.False;
}
public void StartOperation([In] IBindCtx? pbcReserved)
{
ExpectNotDisposed();
this.IsOperationInProgress = true;
this.AsyncOperationStarted?.Invoke(this, EventArgs.Empty);
}
public void EndOperation([In] int hResult, [In] IBindCtx? pbcReserved, [In] uint dwEffects)
{
ExpectNotDisposed();
Precondition.Expect(this.IsOperationInProgress, "Operation not started");
//
// Close all streams that were opened.
//
foreach (var stream in this.openedContentStreams)
{
stream.Dispose();
}
this.IsOperationInProgress = false;
this.AsyncOperationCompleted?.Invoke(
this,
((HRESULT)hResult).Succeeded()
? new AsyncOperationEventArgs(null)
: new AsyncOperationEventArgs(Marshal.GetExceptionForHR(hResult)));
}
public void InOperation([Out] out int pfInAsyncOp)
{
ExpectNotDisposed();
pfInAsyncOp = this.IsOperationInProgress ? VariantBool.True : VariantBool.False;
}
//----------------------------------------------------------------------
// IDisposable.
//----------------------------------------------------------------------
public void Dispose()
{
foreach (var stream in this.openedContentStreams)
{
stream.Dispose();
}
this.disposed = true;
}
//----------------------------------------------------------------------
// Inner types.
//----------------------------------------------------------------------
public delegate Stream OpenStreamDelegate();
/// <summary>
/// Represents a virtual file and its metadata.
/// </summary>
public class Descriptor
{
private readonly OpenStreamDelegate openContentStream;
/// <summary>
/// Create a new descriptor.
/// </summary>
/// <remarks>
/// When the data object is disposed, all opened streams
/// are disposed automatically.
/// </remarks>
public Descriptor(
string name,
ulong size,
FileAttributes attributes,
OpenStreamDelegate openContentStream)
{
Precondition.Expect(
!name.Contains("\\"),
"Name must not contain a path separator");
this.Name = name;
this.Size = size;
this.Attributes = attributes;
this.openContentStream = openContentStream;
}
/// <summary>
/// File size.
/// </summary>
public ulong Size { get; }
/// <summary>
/// File name, without path.
/// </summary>
public string Name { get; }
/// <summary>
/// File attributes.
/// </summary>
public FileAttributes Attributes { get; }
/// <summary>
/// File creation time.
/// </summary>
public DateTime? CreationTime { get; set; }
/// <summary>
/// Last access time.
/// </summary>
public DateTime? LastAccessTime { get; set; }
/// <summary>
/// Last change time.
/// </summary>
public DateTime? LastWriteTime { get; set; }
/// <summary>
/// Open the stream that contains the file contents.
/// </summary>
internal Stream OpenStream()
{
return this.openContentStream();
}
/// <summary>
/// Convert to FILEDESCRIPTORW struct.
/// </summary>
internal FILEDESCRIPTORW ToNativeFileDescriptor(bool requireProgressUi)
{
var native = new FILEDESCRIPTORW()
{
dwFlags =
FD_FILESIZE |
FD_UNICODE |
(requireProgressUi ? FD_PROGRESSUI : 0),
cFileName = this.Name,
dwFileAttributes = (uint)this.Attributes,
nFileSizeHigh = (uint)(this.Size >> 32),
nFileSizeLow = (uint)(this.Size & 0xFFFFFFFF),
};
if (this.CreationTime != null)
{
native.dwFlags |= FD_CREATETIME;
native.ftCreationTime = FileTimeFromDateTime(this.CreationTime.Value);
}
if (this.LastAccessTime != null)
{
native.dwFlags |= FD_ACCESSTIME;
native.ftCreationTime = FileTimeFromDateTime(this.LastAccessTime.Value);
}
if (this.LastWriteTime != null)
{
native.dwFlags |= FD_WRITESTIME;
native.ftCreationTime = FileTimeFromDateTime(this.LastWriteTime.Value);
}
return native;
}
/// <summary>
/// Convert to a FILEGROUPDESCRIPTORW, wrapped in a stream.
/// </summary>
internal static MemoryStream ToNativeGroupDescriptorStream(
IList<Descriptor> fileDescriptors)
{
//
// FILEGROUPDESCRIPTORW is a variabe-length struct,
// so we write it member by member.
//
// 1. Write cItems.
//
var stream = new MemoryStream();
stream.Write(BitConverter.GetBytes(fileDescriptors.Count), 0, sizeof(uint));
//
// 2. Write fgd[..].
//
var structSize = Marshal.SizeOf<FILEDESCRIPTORW>();
using (var ptr = GlobalAllocSafeHandle.GlobalAlloc((uint)structSize))
{
var buffer = new byte[structSize];
for (var i = 0; i < fileDescriptors.Count; i++)
{
Marshal.StructureToPtr(
fileDescriptors[i].ToNativeFileDescriptor(true),
ptr.DangerousGetHandle(),
false);
Marshal.Copy(ptr.DangerousGetHandle(), buffer, 0, structSize);
stream.Write(buffer, 0, buffer.Length);
}
return stream;
}
}
/// <summary>
/// Convert DateTime to a FILETIME.
/// </summary>
private static System.Runtime.InteropServices.ComTypes.FILETIME FileTimeFromDateTime(
DateTime dt)
{
var utc = dt.ToFileTimeUtc();
return new System.Runtime.InteropServices.ComTypes.FILETIME()
{
dwHighDateTime = (int)(utc >> 32),
dwLowDateTime = (int)(utc & 0xFFFFFFFF),
};
}
}
public class AsyncOperationEventArgs : EventArgs
{
public Exception? Exception { get; }
public bool Succeeded
{
get => this.Exception == null;
}
internal AsyncOperationEventArgs(Exception? exception)
{
this.Exception = exception;
}
}
//----------------------------------------------------------------------
// Interop declarations.
//----------------------------------------------------------------------
private const uint FD_CREATETIME = 0x00000008;
private const uint FD_ACCESSTIME = 0x00000010;
private const uint FD_WRITESTIME = 0x00000020;
private const uint FD_FILESIZE = 0x00000040;
private const uint FD_PROGRESSUI = 0x00004000;
private const uint FD_UNICODE = 0x80000000;
private const uint GMEM_MOVEABLE = 0x0002;
private const uint GMEM_ZEROINIT = 0x0040;
private const uint GHND = (GMEM_MOVEABLE | GMEM_ZEROINIT);
private const uint GMEM_DDESHARE = 0x2000;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct FILEDESCRIPTORW
{
public uint dwFlags;
/// <summary>
/// The file type identifier.
/// </summary>
public Guid clsid;
/// <summary>
/// The width and height of the file icon.
/// </summary>
public System.Drawing.Size sizel;
/// <summary>
/// The screen coordinates of the file object.
/// </summary>
public System.Drawing.Point pointl;
/// <summary>
/// File attribute flags, in FILE_ATTRIBUTE_ format.
/// </summary>
public uint dwFileAttributes;
/// <summary>
/// The FILETIME structure that contains the time that the file was last accessed.
/// </summary>
public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
/// <summary>
/// The FILETIME structure that contains the time that the file was last accessed.
/// </summary>
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
/// <summary>
/// The FILETIME structure that contains the time of the last write operation.
/// </summary>
public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
/// <summary>
/// The high-order DWORD of the file size, in bytes.
/// </summary>
public uint nFileSizeHigh;
/// <summary>
/// The low-order DWORD of the file size, in bytes.
/// </summary>
public uint nFileSizeLow;
/// <summary>
/// The null-terminated string that contains the name of the file.
/// </summary>
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
}
private static class NativeMethods
{
[DllImport("kernel32.dll", ExactSpelling = true)]
public static extern IntPtr GlobalAlloc(uint uFlags, int dwBytes);
[DllImport("kernel32.dll", ExactSpelling = true)]
public static extern IntPtr GlobalFree(HandleRef handle);
}
/// <summary>
/// Definition of the IDataObjectAsyncCapability (formerly named IAsyncOperation)
/// COM interface.
/// </summary>
[ComImport]
[Guid("3D8B0590-F691-11d2-8EA9-006097DF5BD4")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface IDataObjectAsyncCapability
{
/// <summary>
/// Called by a drop source to specify whether the data object
/// supports asynchronous data extraction.
/// </summary>
void SetAsyncMode([In] int fDoOpAsync);
/// <summary>
/// Called by a drop target to determine whether the data object
/// supports asynchronous data extraction.
/// </summary>
void GetAsyncMode([Out] out int pfIsOpAsync);
/// <summary>
/// Called by a drop target to indicate that asynchronous
/// data extraction is starting.
/// </summary>
/// <param name="pbcReserved"></param>
void StartOperation([In] IBindCtx? pbcReserved);
/// <summary>
/// Called by the drop source to determine whether the target
/// is extracting data asynchronously.
/// </summary>
void InOperation([Out] out int pfInAsyncOp);
/// <summary>
/// Notifies the data object that the asynchronous data
/// extraction has ended.
/// </summary>
void EndOperation(
[In] int hResult,
[In] IBindCtx? pbcReserved,
[In] uint dwEffects);
}
}
}