Hosting a preview handler in WPF, part 1: UI and file associations

As a fanboy of Raymond Chen, I’m always intrigued by Windows Shell. After his three interesting examples of Reading a Contract From the Other Side (hosting control panel applets, providing shell instance with SHSetInstanceExplorer and simulating a drop), I decided to write one myself. Our victim is Preview Handlers and Shell Preview Host. Today’s entry will prepare UI and file association information retrieval for our little program.

TL; DR: Check out the code here. Also, don’t forget the second part.

macOS (formerly OS X) is known for its convenient Quick Look activated with Space. In Windows, the preview pane is activated with Alt+P. Both systems allow developers to serve and consume this functionality. Apple specifically calls out that one might want to host a Quick Look preview, as well as the usual product requirement to provide a custom Quick Look preview for custom file types. Microsoft’s docs focus on implementing a preview handler, and merely explain that both Windows Explorer and Outlook might display previews. Reading the contract of preview handlers from the other side, we can create our own preview handler host.

Reading materials for anyone with extra time:

Relevant materials for this episode:

A preview handler implements several COM interfaces and is registered as shell extension {8895b1c6-b41f-4c1c-a562-0d564250836f}. The name of this shell extension is the interface ID of the most relevant interface, IPreviewHandler. We need to query this for a file extension to know which handler is to be used (which COM class is to be instatiated).

Create UI

What we will do today is to create a UI for our previewer and prepare file association query utilities. Let’s begin by creating a blank WPF application.

Next, we’ll plug some UI. Change the title, the size and the size constraints for MainWindow class, so that our main window is prettier. Change the Grid in MainWindow.xaml to have transparent background (this is important for drag-and-drop to function properly), allow drag-and-drop by setting AllowDrop to True, and add event handlers for DragEnter, DragOver and Drop on Grid. Handle these events. The code for MainWindow.xaml is available here and MainWindow.xaml.cs is here.

Then, create a new window named PreviewWindow. In the default Grid, create three rows. The first row has a button, corresponding to ‘browse’ command, the second row a ContentPresenter to hold the preview content, and the last another button corresponding to ‘refresh’ command. We also want to bind these commands to keyboard accelerators with Window.InputBindings. The XAML code is available here. Go to the source file and modify the class code. See the file here.

Finally, create the two commands, which are available here and here.

COM interop for file associations

Those UIs are not specially hard yet. Now we create the file association query utilitiy. We will need AssocCreate function to create the QueryAssociations object. Import the function from shlwapi.dll as follows:

[DllImport("shlwapi.dll", PreserveSig = true, CallingConvention = CallingConvention.StdCall)]
static extern HResult AssocCreate(Guid clsid, ref Guid riid, out IntPtr ppv);

(The definition for HResult can be found here.) Here, we do not use automatic Runtime Callable Wrapper creation for a little detour. Next we will import the IQueryAssociations interface as follows:

[Flags]
enum AssocF : uint
{
    InitDefaultToStar = 0x00000004,
    NoTruncate = 0x00000020
}

enum AssocStr
{
    ShellExtension = 16
}

[ComImport, Guid("c46ca590-3c3f-11d2-bee6-0000f805ca57"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface IQueryAssociations
{
    [PreserveSig]
    HResult Init([In] AssocF flags, [In, Optional] string pszAssoc, [In, Optional] IntPtr hkProgid, [In, Optional] IntPtr hwnd);
    [PreserveSig]
    HResult GetString([In] AssocF flags, [In] AssocStr str, [In, Optional] string pszExtra, [Out, Optional] StringBuilder pwszOut, [In, Out] ref uint pcchOut);
    // Method slots beyond GetString are ignored since they are not used.
}

The trick here is to ignore method slots beyond GetString, because in COM methods are ordered and those beyond that slot is not useful to us. The order of methods can be found in shlwapi.h in Windows SDK.

The last task for today is to create a wrapper method to query the preview handler class identifier for a given extension. Here we go.

const string IPreviewHandlerIid = "{8895b1c6-b41f-4c1c-a562-0d564250836f}";
static readonly Guid QueryAssociationsClsid = new Guid(0xa07034fd, 0x6caa, 0x4954, 0xac, 0x3f, 0x97, 0xa2, 0x72, 0x16, 0xf9, 0x8a);
static readonly Guid IQueryAssociationsIid = Guid.ParseExact("c46ca590-3c3f-11d2-bee6-0000f805ca57", "d");

public static Guid? FindPreviewHandlerFor(string extension, IntPtr hwnd)
{
    IntPtr pqa;
    var iid = IQueryAssociationsIid;
    var hr = AssocCreate(QueryAssociationsClsid, ref iid, out pqa);
    if ((int)hr < 0)
        return null;
    var queryAssoc = (IQueryAssociations)Marshal.GetUniqueObjectForIUnknown(pqa);
    Marshal.Release(pqa);
    try
    {
        hr = queryAssoc.Init(AssocF.InitDefaultToStar, extension, IntPtr.Zero, hwnd);
        if ((int)hr < 0)
            return null;
        var sb = new StringBuilder(128);
        uint cch = 64;
        hr = queryAssoc.GetString(AssocF.NoTruncate, AssocStr.ShellExtension, IPreviewHandlerIid, sb, ref cch);
        if ((int)hr < 0)
            return null;
        return Guid.Parse(sb.ToString());
    }
    catch
    {
        return null;
    }
    finally
    {
        Marshal.ReleaseComObject(queryAssoc);
    }
}

It is important that we call Marshal.Release on the IQueryAssociations interface before the method returns, otherwise we would have a COM leak. Also, we explicitly manage the lifetime of our QueryAssociations object with Marshal.ReleaseComObject. It is crucial that no other part of our code could ever reuse our RCW. Because queries to valid preview handler shell extensions always return a GUID, 64 characters are more than enough to hold the result. The code for COM interop can be found here.

I have no idea why GetString might want a window handle. The documentation did not explain this. However, we will use a handle if one is available.

So much today’s entry. You can check out the code at this commit.

Please enable JavaScript to view the comments powered by Disqus.