[Peek]Add support for Explorer preview handlers (#28690)

* Add support for preview handlers

* Fix spelling

* Fix DPI resizing and redraw

* Make source into an ObservableProperty

* Add handler visibility property

* Better error handling

* Add support for IInitializeWithItem

* Run preview handlers in separate processes

* Fix redrawing when switching previewers
This commit is contained in:
Dylan Briedis
2023-10-10 07:51:36 -07:00
committed by GitHub
parent e1a2d18d5d
commit e1944df89a
13 changed files with 656 additions and 1 deletions

View File

@@ -1429,6 +1429,7 @@ ppsi
ppsid
ppsrm
ppsrree
ppstm
ppsz
pptal
ppv
@@ -1471,6 +1472,8 @@ psfi
Psr
psrm
psrree
pstatstg
pstm
pstr
pstream
pstrm
@@ -1823,6 +1826,7 @@ STDMETHODCALLTYPE
STDMETHODIMP
stefan
Stereolithography
STGC
STGM
STGMEDIUM
sticpl
@@ -2188,6 +2192,7 @@ wnd
WNDCLASS
WNDCLASSEX
WNDCLASSEXW
WNDCLASSW
WNDPROC
wordpad
workaround

View File

@@ -2,4 +2,9 @@
_SHCONTF
SIGDN
SHGDNF
SIATTRIBFLAGS
SIATTRIBFLAGS
IInitializeWithFile
IInitializeWithItem
IInitializeWithStream
IPreviewHandler
IPreviewHandlerVisuals

View File

@@ -0,0 +1,17 @@
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
<!-- Licensed under the MIT License. See LICENSE in the project root for license information. -->
<UserControl
x:Class="Peek.FilePreviewer.Controls.ShellPreviewHandlerControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Peek.FilePreviewer.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Loaded="UserControl_Loaded"
EffectiveViewportChanged="UserControl_EffectiveViewportChanged"
IsEnabled="False" IsTabStop="True" GotFocus="UserControl_GotFocus"
ActualThemeChanged="{x:Bind UpdatePreviewerTheme}">
</UserControl>

View File

@@ -0,0 +1,209 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.Shell;
using Windows.Win32.UI.WindowsAndMessaging;
namespace Peek.FilePreviewer.Controls
{
[INotifyPropertyChanged]
public unsafe sealed partial class ShellPreviewHandlerControl : UserControl
{
// Mica fallback colors
private static readonly COLORREF LightThemeBgColor = new(0x00f3f3f3);
private static readonly COLORREF DarkThemeBgColor = new(0x00202020);
private static readonly HBRUSH LightThemeBgBrush = PInvoke.CreateSolidBrush(LightThemeBgColor);
private static readonly HBRUSH DarkThemeBgBrush = PInvoke.CreateSolidBrush(DarkThemeBgColor);
[ObservableProperty]
private IPreviewHandler? source;
private HWND containerHwnd;
private WNDPROC containerWndProc;
private HBRUSH containerBgBrush;
private RECT controlRect;
public event EventHandler? HandlerLoaded;
public event EventHandler? HandlerError;
public static readonly DependencyProperty HandlerVisibilityProperty = DependencyProperty.Register(
nameof(HandlerVisibility),
typeof(Visibility),
typeof(ShellPreviewHandlerControl),
new PropertyMetadata(Visibility.Collapsed, new PropertyChangedCallback((d, e) => ((ShellPreviewHandlerControl)d).OnHandlerVisibilityChanged())));
// Must have its own visibility property so resize events can still fire
public Visibility HandlerVisibility
{
get { return (Visibility)GetValue(HandlerVisibilityProperty); }
set { SetValue(HandlerVisibilityProperty, value); }
}
public ShellPreviewHandlerControl()
{
InitializeComponent();
containerWndProc = ContainerWndProc;
}
partial void OnSourceChanged(IPreviewHandler? value)
{
if (Source != null)
{
UpdatePreviewerTheme();
try
{
// Attach the preview handler to the container window
Source.SetWindow(containerHwnd, (RECT*)Unsafe.AsPointer(ref controlRect));
Source.DoPreview();
HandlerLoaded?.Invoke(this, EventArgs.Empty);
}
catch
{
HandlerError?.Invoke(this, EventArgs.Empty);
}
}
}
private void OnHandlerVisibilityChanged()
{
if (HandlerVisibility == Visibility.Visible)
{
PInvoke.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_SHOW);
IsEnabled = true;
// Clears the background from the last previewer
// The brush can only be drawn here because flashes will occur during resize
PInvoke.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, containerBgBrush);
PInvoke.UpdateWindow(containerHwnd);
PInvoke.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, IntPtr.Zero);
PInvoke.InvalidateRect(containerHwnd, (RECT*)null, true);
}
else
{
PInvoke.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_HIDE);
IsEnabled = false;
}
}
private void UpdatePreviewerTheme()
{
COLORREF bgColor, fgColor;
switch (ActualTheme)
{
case ElementTheme.Light:
bgColor = LightThemeBgColor;
fgColor = new COLORREF(0x00000000); // Black
containerBgBrush = LightThemeBgBrush;
break;
case ElementTheme.Dark:
default:
bgColor = DarkThemeBgColor;
fgColor = new COLORREF(0x00FFFFFF); // White
containerBgBrush = DarkThemeBgBrush;
break;
}
if (Source is IPreviewHandlerVisuals visuals)
{
visuals.SetBackgroundColor(bgColor);
visuals.SetTextColor(fgColor);
// Changing the previewer colors might not always redraw itself
PInvoke.InvalidateRect(containerHwnd, (RECT*)null, true);
}
}
private LRESULT ContainerWndProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam)
{
// Here for future use :)
return PInvoke.DefWindowProc(hWnd, msg, wParam, lParam);
}
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
fixed (char* pContainerClassName = "PeekShellPreviewHandlerContainer")
{
PInvoke.RegisterClass(new WNDCLASSW()
{
lpfnWndProc = containerWndProc,
lpszClassName = pContainerClassName,
});
// Create the container window to host the preview handler
containerHwnd = PInvoke.CreateWindowEx(
WINDOW_EX_STYLE.WS_EX_LAYERED,
pContainerClassName,
null,
WINDOW_STYLE.WS_CHILD,
0, // X
0, // Y
0, // Width
0, // Height
(HWND)Win32Interop.GetWindowFromWindowId(XamlRoot.ContentIslandEnvironment.AppWindowId), // Peek UI window
HMENU.Null,
HINSTANCE.Null);
// Allows the preview handlers to display properly
PInvoke.SetLayeredWindowAttributes(containerHwnd, default, byte.MaxValue, LAYERED_WINDOW_ATTRIBUTES_FLAGS.LWA_ALPHA);
}
}
private void UserControl_EffectiveViewportChanged(FrameworkElement sender, EffectiveViewportChangedEventArgs args)
{
var dpi = (float)PInvoke.GetDpiForWindow(containerHwnd) / 96;
// Resize the container window
PInvoke.SetWindowPos(
containerHwnd,
(HWND)0, // HWND_TOP
(int)(Math.Abs(args.EffectiveViewport.X) * dpi),
(int)(Math.Abs(args.EffectiveViewport.Y) * dpi),
(int)(ActualWidth * dpi),
(int)(ActualHeight * dpi),
SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE);
// Resize the preview handler window
controlRect.right = (int)(ActualWidth * dpi);
controlRect.bottom = (int)(ActualHeight * dpi);
try
{
Source?.SetRect((RECT*)Unsafe.AsPointer(ref controlRect));
}
catch
{
}
// Resizing the previewer might not always redraw itself
PInvoke.InvalidateRect(containerHwnd, (RECT*)null, false);
}
private void UserControl_GotFocus(object sender, RoutedEventArgs e)
{
try
{
Source?.SetFocus();
}
catch
{
}
}
}
}

View File

@@ -18,6 +18,13 @@
VerticalAlignment="Center"
IsActive="{x:Bind MatchPreviewState(Previewer.State, previewers:PreviewState.Loading), Mode=OneWay}" />
<controls:ShellPreviewHandlerControl
x:Name="ShellPreviewHandlerPreview"
Source="{x:Bind ShellPreviewHandlerPreviewer.Preview, Mode=OneWay}"
HandlerVisibility="{x:Bind IsPreviewVisible(ShellPreviewHandlerPreviewer, Previewer.State), Mode=OneWay}"
HandlerLoaded="ShellPreviewHandlerPreview_HandlerLoaded"
HandlerError="ShellPreviewHandlerPreview_HandlerError" />
<Image
x:Name="ImagePreview"
MaxWidth="{x:Bind ImagePreviewer.MaxImageSize.Width, Mode=OneWay}"

View File

@@ -49,6 +49,7 @@ namespace Peek.FilePreviewer
[NotifyPropertyChangedFor(nameof(VideoPreviewer))]
[NotifyPropertyChangedFor(nameof(BrowserPreviewer))]
[NotifyPropertyChangedFor(nameof(ArchivePreviewer))]
[NotifyPropertyChangedFor(nameof(ShellPreviewHandlerPreviewer))]
[NotifyPropertyChangedFor(nameof(UnsupportedFilePreviewer))]
private IPreviewer? previewer;
@@ -96,6 +97,8 @@ namespace Peek.FilePreviewer
public IArchivePreviewer? ArchivePreviewer => Previewer as IArchivePreviewer;
public IShellPreviewHandlerPreviewer? ShellPreviewHandlerPreviewer => Previewer as IShellPreviewHandlerPreviewer;
public IUnsupportedFilePreviewer? UnsupportedFilePreviewer => Previewer as IUnsupportedFilePreviewer;
public IFileSystemItem Item
@@ -220,6 +223,9 @@ namespace Peek.FilePreviewer
ArchivePreview.Source = null;
BrowserPreview.Source = null;
ShellPreviewHandlerPreviewer?.Clear();
ShellPreviewHandlerPreview.Source = null;
if (Previewer != null)
{
Previewer.PropertyChanged -= Previewer_PropertyChanged;
@@ -268,6 +274,22 @@ namespace Peek.FilePreviewer
}
}
private void ShellPreviewHandlerPreview_HandlerLoaded(object sender, EventArgs e)
{
if (ShellPreviewHandlerPreviewer != null)
{
ShellPreviewHandlerPreviewer.State = PreviewState.Loaded;
}
}
private void ShellPreviewHandlerPreview_HandlerError(object sender, EventArgs e)
{
if (ShellPreviewHandlerPreviewer != null)
{
ShellPreviewHandlerPreviewer.State = PreviewState.Error;
}
}
private async void KeyboardAccelerator_CtrlC_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args)
{
if (Previewer != null)

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"public": false
}

View File

@@ -0,0 +1,14 @@
IClassFactory
CoGetClassObject
CreateSolidBrush
CreateWindowEx
DefWindowProc
GetDpiForWindow
InvalidateRect
RegisterClass
SetClassLongPtr
SetLayeredWindowAttributes
SetWindowPos
ShowWindow
UpdateWindow
SHCreateItemFromParsingName

View File

@@ -8,6 +8,7 @@
<RuntimeIdentifiers>win10-x86;win10-x64;win10-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PRIResource Include="..\Peek.UI\Strings\en-US\Resources.resw" Link="Strings\en-US\Resources.resw">
@@ -17,6 +18,7 @@
<ItemGroup>
<None Remove="Controls\ArchiveControl.xaml" />
<None Remove="Controls\BrowserControl.xaml" />
<None Remove="Controls\ShellPreviewHandlerControl.xaml" />
<None Remove="Controls\UnsupportedFilePreview\FailedFallbackPreviewControl.xaml" />
<None Remove="Controls\UnsupportedFilePreview\InformationalPreviewControl.xaml" />
<None Remove="FilePreview.xaml" />
@@ -29,6 +31,10 @@
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -38,6 +44,12 @@
<ProjectReference Include="..\Peek.Common\Peek.Common.csproj" />
</ItemGroup>
<ItemGroup>
<Page Update="Controls\ShellPreviewHandlerControl.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="Controls\ArchiveControl.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.Win32.UI.Shell;
namespace Peek.FilePreviewer.Previewers
{
public interface IShellPreviewHandlerPreviewer : IPreviewer
{
public IPreviewHandler? Preview { get; }
public void Clear();
}
}

View File

@@ -29,6 +29,10 @@ namespace Peek.FilePreviewer.Previewers
{
return new ArchivePreviewer(file);
}
else if (ShellPreviewHandlerPreviewer.IsFileTypeSupported(file.Extension))
{
return new ShellPreviewHandlerPreviewer(file);
}
// Other previewer types check their supported file types here
return CreateDefaultPreviewer(file);

View File

@@ -0,0 +1,104 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Runtime.InteropServices;
using Windows.Win32.Foundation;
using Windows.Win32.System.Com;
namespace Peek.FilePreviewer.Previewers.Helpers
{
public unsafe class IStreamWrapper : IStream
{
public Stream Stream { get; }
public IStreamWrapper(Stream stream) => Stream = stream;
public HRESULT Read(void* pv, uint cb, [Optional] uint* pcbRead)
{
try
{
int read = Stream.Read(new Span<byte>(pv, (int)cb));
if (pcbRead != null)
{
*pcbRead = (uint)read;
}
return (HRESULT)0;
}
catch (Exception ex)
{
return (HRESULT)Marshal.GetHRForException(ex);
}
}
public HRESULT Write(void* pv, uint cb, [Optional] uint* pcbWritten)
{
try
{
Stream.Write(new ReadOnlySpan<byte>(pv, (int)cb));
if (pcbWritten != null)
{
*pcbWritten = cb;
}
return (HRESULT)0;
}
catch (Exception ex)
{
return (HRESULT)Marshal.GetHRForException(ex);
}
}
public void Seek(long dlibMove, STREAM_SEEK dwOrigin, [Optional] ulong* plibNewPosition)
{
long position = Stream.Seek(dlibMove, (SeekOrigin)dwOrigin);
if (plibNewPosition != null)
{
*plibNewPosition = (ulong)position;
}
}
public void SetSize(ulong libNewSize)
{
Stream.SetLength((long)libNewSize);
}
public void CopyTo(IStream pstm, ulong cb, [Optional] ulong* pcbRead, [Optional] ulong* pcbWritten)
{
throw new NotSupportedException();
}
public void Commit(STGC grfCommitFlags)
{
throw new NotSupportedException();
}
public void Revert()
{
throw new NotSupportedException();
}
public void LockRegion(ulong libOffset, ulong cb, uint dwLockType)
{
throw new NotSupportedException();
}
public void UnlockRegion(ulong libOffset, ulong cb, uint dwLockType)
{
throw new NotSupportedException();
}
public void Stat(STATSTG* pstatstg, uint grfStatFlag)
{
throw new NotSupportedException();
}
public void Clone(out IStream ppstm)
{
throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Dispatching;
using Microsoft.Win32;
using Peek.Common.Extensions;
using Peek.Common.Helpers;
using Peek.Common.Models;
using Peek.FilePreviewer.Models;
using Peek.FilePreviewer.Previewers.Helpers;
using Windows.Win32;
using Windows.Win32.System.Com;
using Windows.Win32.UI.Shell;
using Windows.Win32.UI.Shell.PropertiesSystem;
using IShellItem = Windows.Win32.UI.Shell.IShellItem;
namespace Peek.FilePreviewer.Previewers
{
public partial class ShellPreviewHandlerPreviewer : ObservableObject, IShellPreviewHandlerPreviewer, IDisposable
{
private static readonly ConcurrentDictionary<Guid, IClassFactory> HandlerFactories = new();
[ObservableProperty]
private IPreviewHandler? preview;
[ObservableProperty]
private PreviewState state;
private Stream? fileStream;
public ShellPreviewHandlerPreviewer(IFileSystemItem file)
{
FileItem = file;
Dispatcher = DispatcherQueue.GetForCurrentThread();
}
private IFileSystemItem FileItem { get; }
private DispatcherQueue Dispatcher { get; }
public void Dispose()
{
Clear();
GC.SuppressFinalize(this);
}
public async Task CopyAsync()
{
await Dispatcher.RunOnUiThread(async () =>
{
var storageItem = await FileItem.GetStorageItemAsync();
ClipboardHelper.SaveToClipboard(storageItem);
});
}
public Task<PreviewSize> GetPreviewSizeAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PreviewSize { MonitorSize = null });
}
public async Task LoadPreviewAsync(CancellationToken cancellationToken)
{
Clear();
State = PreviewState.Loading;
cancellationToken.ThrowIfCancellationRequested();
// Create the preview handler
var previewHandler = await Task.Run(() =>
{
var previewHandlerGuid = GetPreviewHandlerGuid(FileItem.Extension);
if (!string.IsNullOrEmpty(previewHandlerGuid))
{
var clsid = Guid.Parse(previewHandlerGuid);
bool retry = false;
do
{
unsafe
{
// This runs the preview handler in a separate process (prevhost.exe)
// TODO: Figure out how to get it to run in a low integrity level
if (!HandlerFactories.TryGetValue(clsid, out var factory))
{
var hr = PInvoke.CoGetClassObject(clsid, CLSCTX.CLSCTX_LOCAL_SERVER, null, typeof(IClassFactory).GUID, out var pFactory);
Marshal.ThrowExceptionForHR(hr);
// Storing the factory in memory helps makes the handlers load faster
// TODO: Maybe free them after some inactivity or when Peek quits?
factory = (IClassFactory)Marshal.GetObjectForIUnknown((IntPtr)pFactory);
factory.LockServer(true);
HandlerFactories.AddOrUpdate(clsid, factory, (_, _) => factory);
}
try
{
var iid = typeof(IPreviewHandler).GUID;
factory.CreateInstance(null, &iid, out var instance);
return instance as IPreviewHandler;
}
catch
{
if (!retry)
{
// Process is probably dead, attempt to get the factory again (once)
HandlerFactories.TryRemove(new(clsid, factory));
retry = true;
}
else
{
break;
}
}
}
}
while (retry);
}
return null;
});
if (previewHandler == null)
{
State = PreviewState.Error;
return;
}
cancellationToken.ThrowIfCancellationRequested();
// Initialize the preview handler with the selected file
bool success = await Task.Run(() =>
{
const uint STGM_READ = 0x00000000;
if (previewHandler is IInitializeWithStream initWithStream)
{
fileStream = File.OpenRead(FileItem.Path);
initWithStream.Initialize(new IStreamWrapper(fileStream), STGM_READ);
}
else if (previewHandler is IInitializeWithItem initWithItem)
{
var hr = PInvoke.SHCreateItemFromParsingName(FileItem.Path, null, typeof(IShellItem).GUID, out var item);
Marshal.ThrowExceptionForHR(hr);
initWithItem.Initialize((IShellItem)item, STGM_READ);
}
else if (previewHandler is IInitializeWithFile initWithFile)
{
unsafe
{
fixed (char* pPath = FileItem.Path)
{
initWithFile.Initialize(pPath, STGM_READ);
}
}
}
else
{
// Handler is missing the required interfaces
return false;
}
return true;
});
if (!success)
{
State = PreviewState.Error;
return;
}
cancellationToken.ThrowIfCancellationRequested();
// Preview.SetWindow() needs to be set in the control
Preview = previewHandler;
}
public void Clear()
{
if (Preview != null)
{
try
{
Preview.Unload();
Marshal.FinalReleaseComObject(Preview);
}
catch
{
}
Preview = null;
}
if (fileStream != null)
{
fileStream.Dispose();
fileStream = null;
}
}
public static bool IsFileTypeSupported(string fileExt)
{
return !string.IsNullOrEmpty(GetPreviewHandlerGuid(fileExt));
}
private static string? GetPreviewHandlerGuid(string fileExt)
{
const string PreviewHandlerKeyPath = "shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}";
// Search by file extension
using var classExtensionKey = Registry.ClassesRoot.OpenSubKey(fileExt);
using var classExtensionPreviewHandlerKey = classExtensionKey?.OpenSubKey(PreviewHandlerKeyPath);
if (classExtensionKey != null && classExtensionPreviewHandlerKey == null)
{
// Search by file class
var className = classExtensionKey.GetValue(null) as string;
if (!string.IsNullOrEmpty(className))
{
using var classKey = Registry.ClassesRoot.OpenSubKey(className);
using var classPreviewHandlerKey = classKey?.OpenSubKey(PreviewHandlerKeyPath);
return classPreviewHandlerKey?.GetValue(null) as string;
}
}
return classExtensionPreviewHandlerKey?.GetValue(null) as string;
}
}
}