diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c5b83b656db..6d7695fdb18 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1430,6 +1430,7 @@ ppsi ppsid ppsrm ppsrree +ppstm ppsz pptal ppv @@ -1472,6 +1473,8 @@ psfi Psr psrm psrree +pstatstg +pstm pstr pstream pstrm @@ -1826,6 +1829,7 @@ STDMETHODCALLTYPE STDMETHODIMP stefan Stereolithography +STGC STGM STGMEDIUM sticpl @@ -2191,6 +2195,7 @@ wnd WNDCLASS WNDCLASSEX WNDCLASSEXW +WNDCLASSW WNDPROC wordpad workaround diff --git a/src/modules/peek/Peek.Common/NativeMethods.txt b/src/modules/peek/Peek.Common/NativeMethods.txt index c5ac7c03994..5fed179af89 100644 --- a/src/modules/peek/Peek.Common/NativeMethods.txt +++ b/src/modules/peek/Peek.Common/NativeMethods.txt @@ -2,4 +2,9 @@ _SHCONTF SIGDN SHGDNF -SIATTRIBFLAGS \ No newline at end of file +SIATTRIBFLAGS +IInitializeWithFile +IInitializeWithItem +IInitializeWithStream +IPreviewHandler +IPreviewHandlerVisuals \ No newline at end of file diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml new file mode 100644 index 00000000000..b67292ec4b4 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs new file mode 100644 index 00000000000..006fd76a844 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs @@ -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 + { + } + } + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index 9d1539cdd94..12ba4874b5d 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -18,6 +18,13 @@ VerticalAlignment="Center" IsActive="{x:Bind MatchPreviewState(Previewer.State, previewers:PreviewState.Loading), Mode=OneWay}" /> + + - + Previewer as IArchivePreviewer; + public IShellPreviewHandlerPreviewer? ShellPreviewHandlerPreviewer => Previewer as IShellPreviewHandlerPreviewer; + public IUnsupportedFilePreviewer? UnsupportedFilePreviewer => Previewer as IUnsupportedFilePreviewer; public IFileSystemItem Item @@ -220,6 +223,9 @@ partial void OnPreviewerChanging(IPreviewer? value) ArchivePreview.Source = null; BrowserPreview.Source = null; + ShellPreviewHandlerPreviewer?.Clear(); + ShellPreviewHandlerPreview.Source = null; + if (Previewer != null) { Previewer.PropertyChanged -= Previewer_PropertyChanged; @@ -268,6 +274,22 @@ private void PreviewBrowser_NavigationCompleted(WebView2 sender, CoreWebView2Nav } } + 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) diff --git a/src/modules/peek/Peek.FilePreviewer/NativeMethods.json b/src/modules/peek/Peek.FilePreviewer/NativeMethods.json new file mode 100644 index 00000000000..dc43b588613 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": false +} \ No newline at end of file diff --git a/src/modules/peek/Peek.FilePreviewer/NativeMethods.txt b/src/modules/peek/Peek.FilePreviewer/NativeMethods.txt new file mode 100644 index 00000000000..2a6906e2e90 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/NativeMethods.txt @@ -0,0 +1,14 @@ +IClassFactory +CoGetClassObject +CreateSolidBrush +CreateWindowEx +DefWindowProc +GetDpiForWindow +InvalidateRect +RegisterClass +SetClassLongPtr +SetLayeredWindowAttributes +SetWindowPos +ShowWindow +UpdateWindow +SHCreateItemFromParsingName \ No newline at end of file diff --git a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj index 02eb4c4dc39..1a8832a4e0e 100644 --- a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj +++ b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj @@ -8,6 +8,7 @@ win10-x86;win10-x64;win10-arm64 true enable + true @@ -17,6 +18,7 @@ + @@ -29,6 +31,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -38,6 +44,12 @@ + + + MSBuild:Compile + + + MSBuild:Compile diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IShellPreviewHandlerPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IShellPreviewHandlerPreviewer.cs new file mode 100644 index 00000000000..3d71ea926a2 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IShellPreviewHandlerPreviewer.cs @@ -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(); + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs index 3ac9ceeceec..8a505d9f9cf 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs @@ -29,6 +29,10 @@ public IPreviewer Create(IFileSystemItem file) { 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); diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs new file mode 100644 index 00000000000..786bd0d3b81 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs @@ -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(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(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(); + } + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs new file mode 100644 index 00000000000..1d13c4412d7 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs @@ -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 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 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; + } + } +}