diff --git a/README.md b/README.md index 334d555..abb78e6 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,22 @@ winget install WinQuickLook --source msstore That's it! WinQuickLook will run in the background, ready whenever you need it. +## 🛠️ Development + +The repository keeps the current WPF app and the Windows App SDK / WinUI 3 app separated: + +- `src/WinQuickLook.App` - WPF app entry point and WPF preview UI. +- `src/WinQuickLook.Core` - WPF app preview handlers and shared WPF-era services. +- `src/WinQuickLook.App.WinUI` - WinUI app entry point, tray integration, Explorer selection, and WinUI preview window. +- `src/WinQuickLook.Preview` - UI-independent preview provider layer used by the WinUI app. +- `src/WinQuickLook.Shell` - WPF-free shell integration used by the WinUI app. + +Run the WinUI 3 app during vNext development: + +```bash +dotnet run --project src/WinQuickLook.App.WinUI/WinQuickLook.App.WinUI.csproj -p:Platform=x64 +``` + ## 💡 Use Cases - **Quick Document Review** - Scan through multiple documents without opening Word, PDF readers, etc. diff --git a/WinQuickLook.slnx b/WinQuickLook.slnx index 5cb8472..0478223 100644 --- a/WinQuickLook.slnx +++ b/WinQuickLook.slnx @@ -8,10 +8,22 @@ + + + + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/App.xaml b/src/WinQuickLook.App.WinUI/App.xaml new file mode 100644 index 0000000..bff833e --- /dev/null +++ b/src/WinQuickLook.App.WinUI/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/WinQuickLook.App.WinUI/App.xaml.cs b/src/WinQuickLook.App.WinUI/App.xaml.cs new file mode 100644 index 0000000..227b3b1 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/App.xaml.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Threading; + +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +using Windows.Win32.UI.Input.KeyboardAndMouse; + +using WinQuickLook.Messaging; +using WinQuickLook.Preview; +using WinQuickLook.Providers; + +using WinRT.Interop; + +namespace WinQuickLook.App.WinUI; + +public partial class App +{ + private readonly Mutex _mutex = new(false, AppParameters.Title); + private readonly LowLevelKeyboardHook _keyboardHook = new(); + private readonly ShellExplorerProvider _shellExplorerProvider = new(); + private readonly IPreviewProvider _previewProvider = AggregatePreviewProvider.CreateDefault(); + private readonly DispatcherQueue _dispatcherQueue; + + private Window? _messageWindow; + private PreviewWindow? _previewWindow; + private NativeNotifyIcon? _notifyIcon; + private bool _mutexAcquired; + private bool _disposed; + + public App() + { + InitializeComponent(); + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + AppDomain.CurrentDomain.ProcessExit += (_, _) => DisposeServices(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + if (!_mutex.WaitOne(0, false)) + { + Exit(); + + return; + } + + _mutexAcquired = true; + + ConfigureNotifyIcon(); + ConfigureKeyboardHook(); + } + + private void ConfigureNotifyIcon() + { + _messageWindow = new Window(); + + var hwnd = WindowNative.GetWindowHandle(_messageWindow); + + WindowComposition.SetCloaked(hwnd, true); + _messageWindow.Activate(); + NativeNotifyIcon.HideWindow(hwnd); + + _notifyIcon = new NativeNotifyIcon( + hwnd, + AppParameters.Title, + () => _dispatcherQueue.TryEnqueue(PerformPreview), + () => _dispatcherQueue.TryEnqueue(ExitApplication)); + } + + private void ConfigureKeyboardHook() + { + _keyboardHook.PerformKeyDown = vkCode => + { + switch (vkCode) + { + case VIRTUAL_KEY.VK_SPACE: + _dispatcherQueue.TryEnqueue(PerformPreview); + break; + case VIRTUAL_KEY.VK_ESCAPE: + _dispatcherQueue.TryEnqueue(ClosePreview); + break; + } + }; + + _keyboardHook.Start(); + } + + private void PerformPreview() + { + if (_previewWindow is not null) + { + ClosePreview(); + + return; + } + + var fileSystemInfo = _shellExplorerProvider.GetSelectedItem(); + + if (fileSystemInfo is null || !_previewProvider.CanPreview(fileSystemInfo)) + { + return; + } + + var request = fileSystemInfo is FileInfo fileInfo + ? PreviewRequestFactory.CreateWithDirectorySiblings(fileInfo.FullName, _previewProvider) + : PreviewRequestFactory.Create(fileSystemInfo.FullName); + + _previewWindow = new PreviewWindow(_previewProvider, request); + _previewWindow.Closed += (_, _) => _previewWindow = null; + _previewWindow.Activate(); + } + + private void ClosePreview() + { + var previewWindow = _previewWindow; + + if (previewWindow is null) + { + return; + } + + _previewWindow = null; + previewWindow.Close(); + } + + private void ExitApplication() + { + DisposeServices(); + Exit(); + } + + private void DisposeServices() + { + if (_disposed) + { + return; + } + + ClosePreview(); + _keyboardHook.Dispose(); + + if (_notifyIcon is not null) + { + _notifyIcon.Dispose(); + _notifyIcon = null; + } + + if (_mutexAcquired) + { + _mutex.ReleaseMutex(); + _mutexAcquired = false; + } + + _mutex.Dispose(); + _disposed = true; + } +} diff --git a/src/WinQuickLook.App.WinUI/AppParameters.cs b/src/WinQuickLook.App.WinUI/AppParameters.cs new file mode 100644 index 0000000..03e9c47 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/AppParameters.cs @@ -0,0 +1,6 @@ +namespace WinQuickLook.App.WinUI; + +public static class AppParameters +{ + public const string Title = "WinQuickLook"; +} diff --git a/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs b/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs new file mode 100644 index 0000000..30266e1 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/NativeNotifyIcon.cs @@ -0,0 +1,242 @@ +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace WinQuickLook.App.WinUI; + +internal sealed class NativeNotifyIcon : IDisposable +{ + private const uint CallbackMessage = 0x8000 + 1; + private const uint IconId = 1; + private const uint PreviewCommandId = 100; + private const uint ExitCommandId = 101; + + private const uint NIM_ADD = 0x00000000; + private const uint NIM_DELETE = 0x00000002; + private const uint NIM_SETVERSION = 0x00000004; + private const uint NIF_MESSAGE = 0x00000001; + private const uint NIF_ICON = 0x00000002; + private const uint NIF_TIP = 0x00000004; + private const uint NOTIFYICON_VERSION_4 = 4; + private const uint WM_LBUTTONDBLCLK = 0x0203; + private const uint WM_RBUTTONUP = 0x0205; + private const int GWLP_WNDPROC = -4; + private const uint MF_STRING = 0x00000000; + private const uint MF_SEPARATOR = 0x00000800; + private const uint TPM_RETURNCMD = 0x00000100; + private const uint TPM_RIGHTBUTTON = 0x00000002; + private const int SW_HIDE = 0; + + private readonly nint _hwnd; + private readonly Action _previewSelected; + private readonly Action _exitSelected; + private readonly WndProc _wndProc; + private readonly nint _oldWndProc; + private readonly nint _icon; + private readonly bool _destroyIcon; + + private bool _disposed; + + public NativeNotifyIcon(nint hwnd, string tooltip, Action previewSelected, Action exitSelected) + { + _hwnd = hwnd; + _previewSelected = previewSelected; + _exitSelected = exitSelected; + _wndProc = WndProcCore; + _oldWndProc = SetWindowLongPtr(_hwnd, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProc)); + _icon = ExtractIcon(nint.Zero, Environment.ProcessPath!, 0); + + if (_icon == nint.Zero) + { + _icon = LoadIcon(nint.Zero, new IntPtr(32512)); + } + else + { + _destroyIcon = true; + } + + var data = CreateNotifyIconData(tooltip); + + if (!Shell_NotifyIcon(NIM_ADD, ref data)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + data.uVersion = NOTIFYICON_VERSION_4; + Shell_NotifyIcon(NIM_SETVERSION, ref data); + } + + public static void HideWindow(nint hwnd) + { + ShowWindow(hwnd, SW_HIDE); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + var data = CreateNotifyIconData(string.Empty); + + Shell_NotifyIcon(NIM_DELETE, ref data); + SetWindowLongPtr(_hwnd, GWLP_WNDPROC, _oldWndProc); + if (_destroyIcon) + { + DestroyIcon(_icon); + } + + _disposed = true; + } + + private NotifyIconData CreateNotifyIconData(string tooltip) + { + return new NotifyIconData + { + cbSize = Marshal.SizeOf(), + hWnd = _hwnd, + uID = IconId, + uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP, + uCallbackMessage = CallbackMessage, + hIcon = _icon, + szTip = tooltip, + szInfo = string.Empty, + szInfoTitle = string.Empty + }; + } + + private nint WndProcCore(nint hwnd, uint message, nuint wParam, nint lParam) + { + if (message == CallbackMessage) + { + switch ((uint)lParam.ToInt64() & 0xFFFF) + { + case WM_LBUTTONDBLCLK: + _previewSelected(); + return 0; + case WM_RBUTTONUP: + ShowContextMenu(); + return 0; + } + } + + return CallWindowProc(_oldWndProc, hwnd, message, wParam, lParam); + } + + private void ShowContextMenu() + { + if (!GetCursorPos(out var point)) + { + return; + } + + var menu = CreatePopupMenu(); + + if (menu == nint.Zero) + { + return; + } + + try + { + AppendMenu(menu, MF_STRING, PreviewCommandId, "Preview selected"); + AppendMenu(menu, MF_SEPARATOR, 0, null); + AppendMenu(menu, MF_STRING, ExitCommandId, "Exit"); + SetForegroundWindow(_hwnd); + + var command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, point.X, point.Y, 0, _hwnd, nint.Zero); + + switch (command) + { + case PreviewCommandId: + _previewSelected(); + break; + case ExitCommandId: + _exitSelected(); + break; + } + } + finally + { + DestroyMenu(menu); + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Shell_NotifyIcon(uint dwMessage, ref NotifyIconData lpData); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] + private static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong); + + [DllImport("user32.dll")] + private static extern nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [DllImport("user32.dll", SetLastError = true)] + private static extern nint CreatePopupMenu(); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool AppendMenu(nint hMenu, uint uFlags, uint uIDNewItem, string? lpNewItem); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint TrackPopupMenu(nint hMenu, uint uFlags, int x, int y, int nReserved, nint hWnd, nint prcRect); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyMenu(nint hMenu); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(nint hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(nint hWnd, int nCmdShow); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern nint ExtractIcon(nint hInst, string lpszExeFileName, uint nIconIndex); + + [DllImport("user32.dll")] + private static extern nint LoadIcon(nint hInstance, nint lpIconName); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(nint hIcon); + + private delegate nint WndProc(nint hwnd, uint message, nuint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct NotifyIconData + { + public int cbSize; + public nint hWnd; + public uint uID; + public uint uFlags; + public uint uCallbackMessage; + public nint hIcon; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string szTip; + + public uint dwState; + public uint dwStateMask; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string szInfo; + + public uint uVersion; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string szInfoTitle; + + public uint dwInfoFlags; + public Guid guidItem; + public nint hBalloonIcon; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } +} diff --git a/src/WinQuickLook.App.WinUI/PreviewWindow.xaml b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml new file mode 100644 index 0000000..d78b637 --- /dev/null +++ b/src/WinQuickLook.App.WinUI/PreviewWindow.xaml @@ -0,0 +1,141 @@ + + + + + + + + {{body}} + + + """; + } +} diff --git a/src/WinQuickLook.Preview/MediaPreviewPayload.cs b/src/WinQuickLook.Preview/MediaPreviewPayload.cs new file mode 100644 index 0000000..a0b9339 --- /dev/null +++ b/src/WinQuickLook.Preview/MediaPreviewPayload.cs @@ -0,0 +1,3 @@ +namespace WinQuickLook.Preview; + +public sealed record MediaPreviewPayload(string FilePath, bool HasVideo); diff --git a/src/WinQuickLook.Preview/MediaPreviewProvider.cs b/src/WinQuickLook.Preview/MediaPreviewProvider.cs new file mode 100644 index 0000000..573f325 --- /dev/null +++ b/src/WinQuickLook.Preview/MediaPreviewProvider.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public sealed class MediaPreviewProvider : IPreviewProvider +{ + public static readonly FrozenSet VideoExtensions = new[] + { + ".avi", + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".mpeg", + ".mpg", + ".webm", + ".wmv" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public static readonly FrozenSet AudioExtensions = new[] + { + ".aac", + ".flac", + ".m4a", + ".mp3", + ".ogg", + ".wav", + ".wma" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo + && fileInfo.Exists + && (VideoExtensions.Contains(fileInfo.Extension) || AudioExtensions.Contains(fileInfo.Extension)); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + var hasVideo = VideoExtensions.Contains(fileInfo.Extension); + + result = new PreviewResult( + hasVideo ? PreviewKind.Video : PreviewKind.Audio, + fileInfo.FullName, + fileInfo.Name, + new MediaPreviewPayload(fileInfo.FullName, hasVideo)); + + return true; + } +} diff --git a/src/WinQuickLook.Preview/PreviewKind.cs b/src/WinQuickLook.Preview/PreviewKind.cs new file mode 100644 index 0000000..7064404 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewKind.cs @@ -0,0 +1,15 @@ +namespace WinQuickLook.Preview; + +public enum PreviewKind +{ + Image, + Text, + Markdown, + Web, + Video, + Audio, + Pdf, + Directory, + Generic, + Unsupported +} diff --git a/src/WinQuickLook.Preview/PreviewRequest.cs b/src/WinQuickLook.Preview/PreviewRequest.cs new file mode 100644 index 0000000..4911525 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewRequest.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace WinQuickLook.Preview; + +public sealed record PreviewRequest( + string FilePath, + IReadOnlyList? SiblingFilePaths = null, + int CurrentIndex = 0); diff --git a/src/WinQuickLook.Preview/PreviewRequestFactory.cs b/src/WinQuickLook.Preview/PreviewRequestFactory.cs new file mode 100644 index 0000000..c43be03 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewRequestFactory.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace WinQuickLook.Preview; + +public static class PreviewRequestFactory +{ + public static PreviewRequest Create(string filePath, IReadOnlyList? siblingFilePaths = null) + { + var fullPath = Path.GetFullPath(filePath); + + if (siblingFilePaths is null || siblingFilePaths.Count == 0) + { + return new PreviewRequest(fullPath); + } + + var siblings = siblingFilePaths + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var currentIndex = Array.FindIndex(siblings, x => string.Equals(x, fullPath, StringComparison.OrdinalIgnoreCase)); + + if (currentIndex >= 0) + { + return new PreviewRequest(fullPath, siblings, currentIndex); + } + + var expandedSiblings = siblings + .Append(fullPath) + .ToArray(); + + return new PreviewRequest(fullPath, expandedSiblings, expandedSiblings.Length - 1); + } + + public static PreviewRequest CreateWithDirectorySiblings(string filePath, IPreviewProvider provider) + { + if (Directory.Exists(filePath)) + { + return Create(filePath); + } + + var fileInfo = new FileInfo(filePath); + + if (fileInfo.Directory is null || !fileInfo.Directory.Exists) + { + return Create(fileInfo.FullName); + } + + try + { + var siblings = fileInfo.Directory + .EnumerateFiles() + .Where(provider.CanPreview) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.FullName, StringComparer.OrdinalIgnoreCase) + .Select(x => x.FullName) + .ToArray(); + + return Create(fileInfo.FullName, siblings); + } + catch (IOException) + { + return Create(fileInfo.FullName); + } + catch (UnauthorizedAccessException) + { + return Create(fileInfo.FullName); + } + } +} diff --git a/src/WinQuickLook.Preview/PreviewResult.cs b/src/WinQuickLook.Preview/PreviewResult.cs new file mode 100644 index 0000000..ca6ee85 --- /dev/null +++ b/src/WinQuickLook.Preview/PreviewResult.cs @@ -0,0 +1,7 @@ +namespace WinQuickLook.Preview; + +public sealed record PreviewResult( + PreviewKind Kind, + string FilePath, + string? Title = null, + object? Payload = null); diff --git a/src/WinQuickLook.Preview/TextPreviewPayload.cs b/src/WinQuickLook.Preview/TextPreviewPayload.cs new file mode 100644 index 0000000..ace5eb2 --- /dev/null +++ b/src/WinQuickLook.Preview/TextPreviewPayload.cs @@ -0,0 +1,7 @@ +namespace WinQuickLook.Preview; + +public sealed record TextPreviewPayload( + string FilePath, + string Text, + string? Language = null, + bool IsTruncated = false); diff --git a/src/WinQuickLook.Preview/TextPreviewProvider.cs b/src/WinQuickLook.Preview/TextPreviewProvider.cs new file mode 100644 index 0000000..49b0352 --- /dev/null +++ b/src/WinQuickLook.Preview/TextPreviewProvider.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text; + +namespace WinQuickLook.Preview; + +public sealed class TextPreviewProvider : IPreviewProvider +{ + private const int MaxPreviewBytes = 1024 * 1024; + + public static readonly FrozenSet SupportedExtensions = new[] + { + ".bat", + ".c", + ".cmd", + ".config", + ".cpp", + ".cs", + ".csproj", + ".css", + ".csv", + ".fs", + ".fsproj", + ".go", + ".h", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".kt", + ".log", + ".markdown", + ".md", + ".mjs", + ".props", + ".ps1", + ".psd1", + ".psm1", + ".py", + ".rb", + ".rs", + ".scss", + ".sh", + ".sln", + ".slnx", + ".sql", + ".swift", + ".targets", + ".ts", + ".tsx", + ".txt", + ".vb", + ".vbproj", + ".vue", + ".xaml", + ".xml", + ".yaml", + ".yml" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && SupportedExtensions.Contains(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo)) + { + result = null; + + return false; + } + + try + { + var bytes = File.ReadAllBytes(fileInfo.FullName); + var isTruncated = bytes.Length > MaxPreviewBytes; + + if (isTruncated) + { + Array.Resize(ref bytes, MaxPreviewBytes); + } + + if (ContainsNullByte(bytes)) + { + result = null; + + return false; + } + + result = new PreviewResult( + PreviewKind.Text, + fileInfo.FullName, + fileInfo.Name, + new TextPreviewPayload(fileInfo.FullName, DecodeText(bytes), GetLanguage(fileInfo.Extension), isTruncated)); + + return true; + } + catch (IOException) + { + result = null; + + return false; + } + catch (UnauthorizedAccessException) + { + result = null; + + return false; + } + } + + private static bool ContainsNullByte(byte[] bytes) + { + return bytes.Contains((byte)0); + } + + private static string DecodeText(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } + + private static string? GetLanguage(string extension) + { + return extension.ToLowerInvariant() switch + { + ".bat" or ".cmd" => "Batch", + ".c" or ".h" => "C", + ".config" or ".props" or ".targets" or ".xml" or ".xaml" or ".csproj" or ".vbproj" or ".fsproj" => "XML", + ".cpp" or ".hpp" => "C++", + ".cs" => "C#", + ".css" or ".scss" => "CSS", + ".csv" => "CSV", + ".fs" => "F#", + ".go" => "Go", + ".htm" or ".html" or ".vue" => "HTML", + ".java" => "Java", + ".js" or ".jsx" or ".mjs" => "JavaScript", + ".json" => "JSON", + ".kt" => "Kotlin", + ".md" or ".markdown" => "Markdown", + ".ps1" or ".psd1" or ".psm1" => "PowerShell", + ".py" => "Python", + ".rb" => "Ruby", + ".rs" => "Rust", + ".sh" => "Shell", + ".sln" or ".slnx" => "Solution", + ".sql" => "SQL", + ".swift" => "Swift", + ".ts" or ".tsx" => "TypeScript", + ".vb" => "Visual Basic", + ".yaml" or ".yml" => "YAML", + _ => null + }; + } +} diff --git a/src/WinQuickLook.Preview/WebDocumentKind.cs b/src/WinQuickLook.Preview/WebDocumentKind.cs new file mode 100644 index 0000000..a3aa563 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentKind.cs @@ -0,0 +1,9 @@ +namespace WinQuickLook.Preview; + +public enum WebDocumentKind +{ + Html, + Markdown, + Pdf, + Svg +} diff --git a/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs b/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs new file mode 100644 index 0000000..70f4858 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentPreviewPayload.cs @@ -0,0 +1,6 @@ +namespace WinQuickLook.Preview; + +public sealed record WebDocumentPreviewPayload( + string FilePath, + WebDocumentKind Kind, + string? Html = null); diff --git a/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs b/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs new file mode 100644 index 0000000..7e30603 --- /dev/null +++ b/src/WinQuickLook.Preview/WebDocumentPreviewProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace WinQuickLook.Preview; + +public sealed class WebDocumentPreviewProvider : IPreviewProvider +{ + private static readonly FrozenDictionary s_supportedExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".htm"] = WebDocumentKind.Html, + [".html"] = WebDocumentKind.Html, + [".xhtml"] = WebDocumentKind.Html, + [".pdf"] = WebDocumentKind.Pdf, + [".svg"] = WebDocumentKind.Svg + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + public bool CanPreview(FileSystemInfo fileSystemInfo) + { + return fileSystemInfo is FileInfo fileInfo && fileInfo.Exists && s_supportedExtensions.ContainsKey(fileInfo.Extension); + } + + public bool TryCreatePreview(PreviewRequest request, [NotNullWhen(true)] out PreviewResult? result) + { + var fileInfo = new FileInfo(request.FilePath); + + if (!CanPreview(fileInfo) || !s_supportedExtensions.TryGetValue(fileInfo.Extension, out var kind)) + { + result = null; + + return false; + } + + result = new PreviewResult( + GetPreviewKind(kind), + fileInfo.FullName, + fileInfo.Name, + new WebDocumentPreviewPayload(fileInfo.FullName, kind)); + + return true; + } + + private static PreviewKind GetPreviewKind(WebDocumentKind kind) + { + return kind switch + { + WebDocumentKind.Pdf => PreviewKind.Pdf, + _ => PreviewKind.Web + }; + } +} diff --git a/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj b/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj new file mode 100644 index 0000000..d0b0458 --- /dev/null +++ b/src/WinQuickLook.Preview/WinQuickLook.Preview.csproj @@ -0,0 +1,11 @@ + + + + WinQuickLook.Preview + + + + + + + diff --git a/src/WinQuickLook.Preview/packages.lock.json b/src/WinQuickLook.Preview/packages.lock.json new file mode 100644 index 0000000..500914c --- /dev/null +++ b/src/WinQuickLook.Preview/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.26100": { + "Markdig": { + "type": "Direct", + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" + } + } + } +} \ No newline at end of file diff --git a/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs b/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs new file mode 100644 index 0000000..828670d --- /dev/null +++ b/src/WinQuickLook.Shell/Messaging/LowLevelKeyboardHook.cs @@ -0,0 +1,26 @@ +using System; +using System.Runtime.InteropServices; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Input.KeyboardAndMouse; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace WinQuickLook.Messaging; + +public class LowLevelKeyboardHook() : WindowsHook(WINDOWS_HOOK_ID.WH_KEYBOARD_LL) +{ + public Action? PerformKeyDown { get; set; } + + protected override LRESULT HookProc(int code, WPARAM wParam, LPARAM lParam) + { + if (code == PInvoke.HC_ACTION && wParam == PInvoke.WM_KEYDOWN) + { + var kbdllhook = Marshal.PtrToStructure(lParam); + + PerformKeyDown?.Invoke((VIRTUAL_KEY)kbdllhook.vkCode); + } + + return base.HookProc(code, wParam, lParam); + } +} diff --git a/src/WinQuickLook.Shell/Messaging/WindowsHook.cs b/src/WinQuickLook.Shell/Messaging/WindowsHook.cs new file mode 100644 index 0000000..78988b8 --- /dev/null +++ b/src/WinQuickLook.Shell/Messaging/WindowsHook.cs @@ -0,0 +1,58 @@ +using System; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace WinQuickLook.Messaging; + +public abstract class WindowsHook(WINDOWS_HOOK_ID idHook) : IDisposable +{ + private HOOKPROC? _hookProc; + private UnhookWindowsHookExSafeHandle? _hook; + private bool _disposed; + + public void Start() + { + if (_hook is not null) + { + return; + } + + _hookProc = HookProc; + _hook = PInvoke.SetWindowsHookEx(idHook, _hookProc, PInvoke.GetModuleHandle((string)null!), 0); + } + + public void Stop() + { + if (_hook is null) + { + return; + } + + _hook.Close(); + _hook = null; + } + + protected virtual LRESULT HookProc(int code, WPARAM wParam, LPARAM lParam) => PInvoke.CallNextHookEx(_hook, code, wParam, lParam); + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + Stop(); + + _disposed = true; + } + + ~WindowsHook() => Dispose(false); +} diff --git a/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs b/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs new file mode 100644 index 0000000..25ca41b --- /dev/null +++ b/src/WinQuickLook.Shell/Providers/ShellExplorerProvider.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; + +using IServiceProvider = Windows.Win32.System.Com.IServiceProvider; + +namespace WinQuickLook.Providers; + +public class ShellExplorerProvider +{ + public FileSystemInfo? GetSelectedItem() + { + var foregroundHwnd = PInvoke.GetForegroundWindow(); + + if (foregroundHwnd == nint.Zero) + { + return null; + } + + var shellWindows = (IShellWindows)new ShellWindows(); + var fileSystemInfo = default(FileSystemInfo); + + if (IsDesktopWindow(foregroundHwnd)) + { + var pvarLoc = new object(); + + shellWindows.FindWindowSW(pvarLoc, pvarLoc, (int)ShellWindowTypeConstants.SWC_DESKTOP, out var desktopHwnd, (int)ShellWindowFindWindowOptions.SWFO_NEEDDISPATCH, out IWebBrowserApp webBrowserApp); + + if (!IsCaretActive(desktopHwnd)) + { + fileSystemInfo = GetSelectedItemCore(webBrowserApp); + } + + Marshal.ReleaseComObject(webBrowserApp); + } + else + { + shellWindows.get_Count(out var count); + + for (var i = 0; i < count; i++) + { + shellWindows.Item(i, out IWebBrowserApp webBrowserApp); + + try + { + webBrowserApp.get_HWND(out var hwnd); + + if (hwnd != foregroundHwnd || IsCaretActive(hwnd)) + { + continue; + } + + fileSystemInfo = GetSelectedItemCore(webBrowserApp); + + break; + } + finally + { + Marshal.ReleaseComObject(webBrowserApp); + } + } + } + + Marshal.ReleaseComObject(shellWindows); + + return fileSystemInfo; + } + + private FileSystemInfo? GetSelectedItemCore(IWebBrowserApp webBrowserApp) + { + var serviceProvider = (IServiceProvider)webBrowserApp; + + serviceProvider.QueryService(PInvoke.SID_STopLevelBrowser, out IShellBrowser shellBrowser); + shellBrowser.QueryActiveShellView(out var shellView); + + var folderView = (IFolderView)shellView; + + folderView.GetFocusedItem(out var index); + folderView.Item(index, out nint ppidl); + folderView.GetFolder(out IPersistFolder2 persistFolder2); + + var shellFolder = (IShellFolder)persistFolder2; + + shellFolder.GetDisplayNameOf(ppidl, SHGDNF.SHGDN_FORPARSING, out var pName); + + Span pszBuf = new char[PInvoke.MAX_PATH]; + + PInvoke.StrRetToBuf(ref pName, ppidl, pszBuf); + + try + { + var selectedPath = new string(pszBuf.TrimEnd('\0')); + + if (File.Exists(selectedPath)) + { + return new FileInfo(selectedPath); + } + + if (Directory.Exists(selectedPath)) + { + return new DirectoryInfo(selectedPath); + } + + return null; + } + finally + { + Marshal.FreeCoTaskMem(ppidl); + + Marshal.ReleaseComObject(shellFolder); + Marshal.ReleaseComObject(persistFolder2); + Marshal.ReleaseComObject(folderView); + Marshal.ReleaseComObject(shellView); + Marshal.ReleaseComObject(shellBrowser); + Marshal.ReleaseComObject(serviceProvider); + } + } + + private static bool IsCaretActive(HWND hwnd) + { + var threadId = PInvoke.GetWindowThreadProcessId(hwnd); + var info = new GUITHREADINFO + { + cbSize = (uint)Marshal.SizeOf() + }; + + PInvoke.GetGUIThreadInfo(threadId, ref info); + + return info.flags != 0 || info.hwndCaret != nint.Zero; + } + + private static bool IsDesktopWindow(HWND hwnd) + { + Span classNameBuf = new char[64]; + + PInvoke.GetClassName(hwnd, classNameBuf); + + var className = new string(classNameBuf); + + if (className != "Progman" && className != "WorkerW") + { + return false; + } + + return PInvoke.FindWindowEx(hwnd, new HWND(), "SHELLDLL_DefView", null) != nint.Zero; + } +} diff --git a/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj b/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj new file mode 100644 index 0000000..8383a02 --- /dev/null +++ b/src/WinQuickLook.Shell/WinQuickLook.Shell.csproj @@ -0,0 +1,11 @@ + + + + WinQuickLook + + + + + + + diff --git a/src/WinQuickLook.Shell/packages.lock.json b/src/WinQuickLook.Shell/packages.lock.json new file mode 100644 index 0000000..ccc4eec --- /dev/null +++ b/src/WinQuickLook.Shell/packages.lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows10.0.26100": { + "winquicklook.cswin32": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs b/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs new file mode 100644 index 0000000..c7ae776 --- /dev/null +++ b/tests/WinQuickLook.Core.Tests/Preview/ImagePreviewProviderTests.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; + +using WinQuickLook.Preview; + +using Xunit; + +namespace WinQuickLook.Tests.Preview; + +public class ImagePreviewProviderTests +{ + [Theory] + [InlineData("sample.png")] + [InlineData("sample.jpg")] + [InlineData("sample.jpeg")] + [InlineData("sample.bmp")] + [InlineData("sample.gif")] + [InlineData("sample.webp")] + public void CanPreview_ReturnsTrue_ForSupportedImageExtension(string fileName) + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile(fileName); + + var provider = new ImagePreviewProvider(); + + Assert.True(provider.CanPreview(fileInfo)); + } + + [Fact] + public void TryCreatePreview_ReturnsImageResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.png"); + var provider = new ImagePreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Image, result.Kind); + Assert.Equal(fileInfo.FullName, result.FilePath); + Assert.IsType(result.Payload); + } + + [Fact] + public void CreateWithDirectorySiblings_ReturnsOrderedSupportedImages() + { + using var temp = new TemporaryDirectory(); + var first = temp.CreateFile("a.png"); + var current = temp.CreateFile("b.jpg"); + var third = temp.CreateFile("c.webp"); + temp.CreateFile("notes.txt"); + + var request = PreviewRequestFactory.CreateWithDirectorySiblings(current.FullName, new ImagePreviewProvider()); + + Assert.Equal(current.FullName, request.FilePath); + Assert.Equal(1, request.CurrentIndex); + Assert.Equal([first.FullName, current.FullName, third.FullName], request.SiblingFilePaths); + } + + [Fact] + public void AggregatePreviewProvider_CanPreview_TextFile() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.cs", "Console.WriteLine(\"Hello\");"); + + var provider = AggregatePreviewProvider.CreateDefault(); + + Assert.True(provider.CanPreview(fileInfo)); + } + + [Fact] + public void TextPreviewProvider_ReturnsTextResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.json", "{\"ok\":true}"); + var provider = new TextPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Text, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.Equal("{\"ok\":true}", payload.Text); + Assert.Equal("JSON", payload.Language); + } + + [Fact] + public void MarkdownPreviewProvider_ReturnsMarkdownResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("readme.md", "# Hello"); + var provider = new MarkdownPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Markdown, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.Equal(WebDocumentKind.Markdown, payload.Kind); + Assert.Contains("(result.Payload); + Assert.Equal(WebDocumentKind.Pdf, payload.Kind); + } + + [Fact] + public void MediaPreviewProvider_ReturnsAudioResult() + { + using var temp = new TemporaryDirectory(); + var fileInfo = temp.CreateFile("sample.mp3"); + var provider = new MediaPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(fileInfo.FullName), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Audio, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.False(payload.HasVideo); + } + + [Fact] + public void GenericPreviewProvider_ReturnsDirectoryResult() + { + using var temp = new TemporaryDirectory(); + temp.CreateFile("child.txt"); + var provider = new GenericPreviewProvider(); + + var success = provider.TryCreatePreview(new PreviewRequest(temp.DirectoryPath), out var result); + + Assert.True(success); + Assert.NotNull(result); + Assert.Equal(PreviewKind.Directory, result.Kind); + var payload = Assert.IsType(result.Payload); + Assert.True(payload.IsDirectory); + } + + private sealed class TemporaryDirectory : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + public string DirectoryPath => _path; + + public TemporaryDirectory() + { + Directory.CreateDirectory(_path); + } + + public FileInfo CreateFile(string fileName, string content = "") + { + var path = Path.Combine(_path, fileName); + + File.WriteAllText(path, content); + + return new FileInfo(path); + } + + public void Dispose() + { + Directory.Delete(_path, true); + } + } +} diff --git a/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj b/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj index db71c3e..146fc02 100644 --- a/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj +++ b/tests/WinQuickLook.Core.Tests/WinQuickLook.Core.Tests.csproj @@ -16,13 +16,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -32,6 +32,7 @@ + diff --git a/tests/WinQuickLook.Core.Tests/packages.lock.json b/tests/WinQuickLook.Core.Tests/packages.lock.json index a97da18..e874339 100644 --- a/tests/WinQuickLook.Core.Tests/packages.lock.json +++ b/tests/WinQuickLook.Core.Tests/packages.lock.json @@ -4,18 +4,18 @@ "net10.0-windows10.0.26100": { "coverlet.collector": { "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "WFejCcOUR6k8UYyDnnR6Gk+obFYMsWrZuNqPJnsVFGVhpPSN0y20D4qbdKJnXinYGx9PQ397Hf9TnU1NBST8vA==" + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[18.5.1, )", - "resolved": "18.5.1", - "contentHash": "SfqVaLiIqAbRWuPg5BP4QFwBIirQj/YIL8Dhxl6zntBKbXp0cQykoV480SmwG+yRMiWptxEI6NbHQuGSZ8b97w==", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", "dependencies": { - "Microsoft.CodeCoverage": "18.5.1", - "Microsoft.TestPlatform.TestHost": "18.5.1" + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" } }, "Moq": { @@ -71,8 +71,8 @@ }, "Markdig": { "type": "Transitive", - "resolved": "1.1.3", - "contentHash": "wboYc6YToID7koq+onej7rjd/kv4urZaZ4QMiJoF/jXt/ZlXe2Af9gWG4nOe5tk654UaOPYvKL64iFum7qWEYA==" + "resolved": "1.2.0", + "contentHash": "LoBVOgeDWfHthWTPzQJkjDkxImqBXRA5XpuTyjdM4SAURyWQGG1CbEEbfPbmvAavggnAZPaTYpbhIPUurWvIXA==" }, "Microsoft.ApplicationInsights": { "type": "Transitive", @@ -86,8 +86,8 @@ }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "vMFDR1ZjqzzgKmM0zrPie7Gv9Y+ZppjODB5Quzu9Eq0TlIusUfUCYFPEawO91zQuqwzvdFbJSU7WHNtjStffJQ==" + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", @@ -121,22 +121,22 @@ }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "KNZd+M0S0rz5eNAln0pbZX+A/RbokYZCbGKx4fN4CkhtWhkz6nSJDO+9LGYjRE4d0WPVriJ2JnVubkjt3+PpMg==" + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "RM+3JNHEoHOCFXzVntUcIiYxzPjzBN0N8wto6HYXi76YyBTZ/3CeRL8U+Pk5zx3AUrOmHxDvKJwGUCdElU9bJg==", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.5.1", + "Microsoft.TestPlatform.ObjectModel": "18.6.0", "Newtonsoft.Json": "13.0.3" } }, "Microsoft.Web.WebView2": { "type": "Transitive", - "resolved": "1.0.3912.50", - "contentHash": "mEIKZDSmb6CxGWKoxaxTSJ0yi0DD2zp29TA7kJY2GdanuH3c5aYruwfqsm3OsYn/h2hUhybQZvfbxyZe4O+X4Q==" + "resolved": "1.0.3967.48", + "contentHash": "b7dZ5kZ9bfU5Yo11M8feCOoiyalXCN+2Gsw674RBDBmQ2dJV5EOXb8/ivN2LuLJ+VO2V7FmK6l6LsyfEnG1lXw==" }, "Microsoft.Win32.Registry": { "type": "Transitive", @@ -225,13 +225,19 @@ "dependencies": { "AvalonEdit": "[6.3.1.120, )", "Cylinder.WPF": "[1.0.0-preview.2, )", - "Markdig": "[1.1.3, )", - "Microsoft.Web.WebView2": "[1.0.3912.50, )", + "Markdig": "[1.2.0, )", + "Microsoft.Web.WebView2": "[1.0.3967.48, )", "WinQuickLook.CsWin32": "[1.0.0, )" } }, "winquicklook.cswin32": { "type": "Project" + }, + "winquicklook.preview": { + "type": "Project", + "dependencies": { + "Markdig": "[1.2.0, )" + } } } }