diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 94aa42fea6a..1335609001f 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -32,9 +32,9 @@ public static class Constant public static readonly string ErrorIcon = Path.Combine(ImagesDirectory, "app_error.png"); public static readonly string MissingImgIcon = Path.Combine(ImagesDirectory, "app_missing_img.png"); public static readonly string LoadingImgIcon = Path.Combine(ImagesDirectory, "loading.png"); - public static readonly string ImageIcon = Path.Combine(ImagesDirectory, "image.png"); public static readonly string HistoryIcon = Path.Combine(ImagesDirectory, "history.png"); public static readonly string SettingsIcon = Path.Combine(ImagesDirectory, "settings.png"); + public static readonly string FolderIcon = Path.Combine(ImagesDirectory, "folder.png"); public static string PythonPath; public static string NodePath; diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 598347fd21f..83501b16fe2 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Windows; +using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; using Flow.Launcher.Infrastructure.Logger; @@ -24,15 +26,22 @@ public static class ImageLoader private static readonly ConcurrentDictionary GuidToKey = new(); private static ImageHashGenerator _hashGenerator; private static readonly bool EnableImageHash = true; - public static ImageSource Image => ImageCache[Constant.ImageIcon, false]; public static ImageSource MissingImage => ImageCache[Constant.MissingImgIcon, false]; public static ImageSource LoadingImage => ImageCache[Constant.LoadingImgIcon, false]; + public static ImageSource FolderImage => ImageCache[Constant.FolderIcon, false]; public const int SmallIconSize = 64; public const int FullIconSize = 256; public const int FullImageSize = 320; private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico"]; private static readonly string SvgExtension = ".svg"; + internal static Func ShellThumbnailLoader { get; set; } = + (path, option, size) => + WindowsThumbnailProvider.GetThumbnail( + path, + size, + size, + option); public static async Task InitializeAsync() { @@ -46,7 +55,7 @@ public static async Task InitializeAsync() ImageCache.Initialize(usage); - foreach (var icon in new[] { Constant.DefaultIcon, Constant.ImageIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon }) + foreach (var icon in new[] { Constant.DefaultIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon, Constant.FolderIcon }) { ImageSource img = new BitmapImage(new Uri(icon)); img.Freeze(); @@ -149,19 +158,19 @@ private static async ValueTask LoadInternalAsync(string path, bool return new ImageResult(imageSource, ImageType.Data); } - imageResult = await Task.Run(() => GetThumbnailResult(ref path, loadFullImage)); + imageResult = await Task.Run(() => GetThumbnailResult(path, loadFullImage)); } catch (System.Exception e) { try { // Get thumbnail may fail for certain images on the first try, retry again has proven to work - imageResult = GetThumbnailResult(ref path, loadFullImage); + imageResult = GetThumbnailResult(path, loadFullImage); } catch (System.Exception e2) { - Log.Exception(ClassName, $"Failed to get thumbnail for {path} on first try", e); - Log.Exception(ClassName, $"Failed to get thumbnail for {path} on second try", e2); + Log.Warn(ClassName, $"Failed to get thumbnail for {path} on first try: {e.Message}"); + Log.Warn(ClassName, $"Failed to get thumbnail for {path} on second try: {e2.Message}"); ImageSource image = MissingImage; ImageCache[path, false] = image; @@ -195,93 +204,169 @@ private static async Task LoadRemoteImageAsync(bool loadFullImage, return image; } - private static ImageResult GetThumbnailResult(ref string path, bool loadFullImage = false) + private static ImageResult GetThumbnailResult(string path, bool loadFullImage = false) { - ImageSource image; - ImageType type = ImageType.Error; - if (Directory.Exists(path)) + return GetDirectoryThumbnailResult(path, loadFullImage); + + if (!File.Exists(path)) + return GetMissingThumbnailResult(); + + var extension = Path.GetExtension(path).ToLower(); + + if (ImageExtensions.Contains(extension)) + return GetImageFileThumbnailResult(path, loadFullImage); + + if (extension == SvgExtension) + return GetSvgFileThumbnailResult(path, loadFullImage); + + return GetFileThumbnailResult(path, loadFullImage); + } + + private static ImageResult CreateImageResult(ImageSource image, ImageType type) + { + if (type != ImageType.Error && !image.IsFrozen) + { + image.Freeze(); + } + + return new ImageResult(image, type); + } + + private static ImageResult GetMissingThumbnailResult() + { + return CreateImageResult(MissingImage, ImageType.Error); + } + + private static ImageResult GetDirectoryThumbnailResult(string path, bool loadFullImage) + { + var size = loadFullImage ? FullIconSize : SmallIconSize; + try { /* Directories can also have thumbnails instead of shell icons. * Generating thumbnails for a bunch of folder results while scrolling * could have a big impact on performance and Flow.Launcher responsibility. * - Solution: just load the icon */ - type = ImageType.Folder; - image = GetThumbnail(path, ThumbnailOptions.IconOnly); + var image = GetShellThumbnail(path, ThumbnailOptions.IconOnly, size); + return CreateImageResult(image, ImageType.Folder); } - else if (File.Exists(path)) + catch (System.Exception ex) { - var extension = Path.GetExtension(path).ToLower(); - if (ImageExtensions.Contains(extension)) + Log.Warn(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); + return CreateImageResult(FolderImage, ImageType.Folder); + } + } + + private static ImageResult GetImageFileThumbnailResult(string path, bool loadFullImage) + { + if (loadFullImage) + { + try { - type = ImageType.ImageFile; - if (loadFullImage) - { - try - { - image = LoadFullImage(path); - type = ImageType.FullImageFile; - } - catch (NotSupportedException ex) - { - image = Image; - type = ImageType.Error; - Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); - } - } - else - { - /* Although the documentation for GetImage on MSDN indicates that - * if a thumbnail is available it will return one, this has proved to not - * be the case in many situations while testing. - * - Solution: explicitly pass the ThumbnailOnly flag - */ - image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); - } + var image = LoadBitmapImageScaleToFitWithin(path, FullImageSize); + return CreateImageResult(image, ImageType.FullImageFile); } - else if (extension == SvgExtension) + catch (NotSupportedException ex) { - try - { - image = LoadSvgImage(path, loadFullImage); - type = ImageType.FullImageFile; - } - catch (System.Exception ex) - { - image = Image; - type = ImageType.Error; - Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); - } + Log.Warn(ClassName, $"Failed to load image file from path {path}: {ex.Message}\nUsing missing icon instead."); + return GetMissingThumbnailResult(); } - else + } + + try + { + /* Although the documentation for GetImage on MSDN indicates that + * if a thumbnail is available it will return one, this has proved to not + * be the case in many situations while testing. + * - Solution: explicitly pass the ThumbnailOnly flag + */ + var image = GetShellThumbnail(path, ThumbnailOptions.ThumbnailOnly); + return CreateImageResult(image, ImageType.ImageFile); + } + catch (System.Exception ex) + { + Log.Debug(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); + + try + { + var image = LoadBitmapImageScaleToFitWithin(path, SmallIconSize); + return CreateImageResult(image, ImageType.ImageFile); + } + catch (System.Exception ex2) { - type = ImageType.File; - image = GetThumbnail(path, ThumbnailOptions.None, loadFullImage ? FullIconSize : SmallIconSize); + Log.Warn(ClassName, $"Failed to load image file from path {path}: {ex2.Message}\nUsing missing icon instead."); + return GetMissingThumbnailResult(); } } - else + } + + private static ImageResult GetSvgFileThumbnailResult(string path, bool loadFullImage) + { + try + { + var image = LoadSvgImage(path, loadFullImage); + return CreateImageResult(image, ImageType.FullImageFile); + } + catch (System.Exception ex) { - image = MissingImage; - path = Constant.MissingImgIcon; + Log.Warn(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}\nUsing missing icon instead."); + return GetMissingThumbnailResult(); } + } - if (type != ImageType.Error) + private static ImageResult GetFileThumbnailResult(string path, bool loadFullImage) + { + var size = loadFullImage ? FullIconSize : SmallIconSize; + try { - image.Freeze(); + var image = GetShellThumbnail(path, ThumbnailOptions.None, size); + return CreateImageResult(image, ImageType.File); } + catch (System.Exception ex) + { + Log.Debug(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); - return new ImageResult(image, type); + if (TryExtractAssociatedIcon(path, size, out var image)) + { + return CreateImageResult(image, ImageType.File); + } + + Log.Warn(ClassName, $"ExtractAssociatedIcon returned no icon for path: {path}\nUsing missing icon instead."); + return GetMissingThumbnailResult(); + } } - private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, + private static BitmapSource GetShellThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, int size = SmallIconSize) { - return WindowsThumbnailProvider.GetThumbnail( - path, - size, - size, - option); + return ShellThumbnailLoader(path, option, size); + } + + private static bool TryExtractAssociatedIcon(string path, int size, out BitmapSource image) + { + image = null; + + try + { + using var icon = System.Drawing.Icon.ExtractAssociatedIcon(path); + if (icon == null) + { + return false; + } + + image = Imaging.CreateBitmapSourceFromHIcon( + icon.Handle, + Int32Rect.Empty, + BitmapSizeOptions.FromWidthAndHeight(size, size)); + image.Freeze(); + return true; + } + catch + { + image = null; + return false; + } } public static bool CacheContainImage(string path, bool loadFullImage = false) @@ -327,40 +412,87 @@ public static async ValueTask LoadAsync(string path, bool loadFullI return img; } - private static BitmapImage LoadFullImage(string path) + private static bool TryGetBitmapImageDimensionsFromMetadata(string path, out int width, out int height) + { + width = 0; + height = 0; + + try + { + using var stream = File.OpenRead(path); + var decoder = BitmapDecoder.Create( + stream, + BitmapCreateOptions.DelayCreation, + BitmapCacheOption.None); + + var frame = decoder.Frames.FirstOrDefault(); + if (frame is null) + return false; + + width = frame.PixelWidth; + height = frame.PixelHeight; + return width > 0 && height > 0; + } + catch + { + return false; + } + } + + private static BitmapImage LoadBitmapImageScaleToFitWithin(string path, int maxSize) + { + BitmapImage decodedImage = null; + + // try to get the image's dimensions from metadata before fully decoding the image + bool metadataReadSucceeded = TryGetBitmapImageDimensionsFromMetadata(path, out var width, out var height); + + // if we couldn't read the metadata then fully load the image and get dimensions from that + if (!metadataReadSucceeded) + { + decodedImage = LoadBitmapImage(path); + width = decodedImage.PixelWidth; + height = decodedImage.PixelHeight; + } + + // If resizing is unnecessary, return the original image + // (reusing the already decoded image if available). + if (width <= maxSize && height <= maxSize) + { + return decodedImage ?? LoadBitmapImage(path); + } + + bool widthIsLarger = width >= height; + + // LoadBitmapImage will maintain aspect ratio so we only need to scale by the largest dimension + if (widthIsLarger) + { + return LoadBitmapImage(path, decodePixelWidth: maxSize); + } + else + { + return LoadBitmapImage(path, decodePixelHeight: maxSize); + } + } + + private static BitmapImage LoadBitmapImage(string path, int? decodePixelWidth = null, int? decodePixelHeight = null) { BitmapImage image = new BitmapImage(); image.BeginInit(); image.CacheOption = BitmapCacheOption.OnLoad; image.UriSource = new Uri(path); image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - image.EndInit(); - if (image.PixelWidth > FullImageSize) + if (decodePixelWidth.HasValue) { - BitmapImage resizedWidth = new BitmapImage(); - resizedWidth.BeginInit(); - resizedWidth.CacheOption = BitmapCacheOption.OnLoad; - resizedWidth.UriSource = new Uri(path); - resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedWidth.DecodePixelWidth = FullImageSize; - resizedWidth.EndInit(); - - if (resizedWidth.PixelHeight > FullImageSize) - { - BitmapImage resizedHeight = new BitmapImage(); - resizedHeight.BeginInit(); - resizedHeight.CacheOption = BitmapCacheOption.OnLoad; - resizedHeight.UriSource = new Uri(path); - resizedHeight.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedHeight.DecodePixelHeight = FullImageSize; - resizedHeight.EndInit(); - return resizedHeight; - } + image.DecodePixelWidth = decodePixelWidth.Value; + } - return resizedWidth; + if (decodePixelHeight.HasValue) + { + image.DecodePixelHeight = decodePixelHeight.Value; } + image.EndInit(); return image; } diff --git a/Flow.Launcher.Test/Flow.Launcher.Test.csproj b/Flow.Launcher.Test/Flow.Launcher.Test.csproj index 11ccff05b05..2c831bf8304 100644 --- a/Flow.Launcher.Test/Flow.Launcher.Test.csproj +++ b/Flow.Launcher.Test/Flow.Launcher.Test.csproj @@ -58,4 +58,10 @@ + + + PreserveNewest + + + \ No newline at end of file diff --git a/Flow.Launcher.Test/ImageLoaderTests.cs b/Flow.Launcher.Test/ImageLoaderTests.cs new file mode 100644 index 00000000000..155746856b1 --- /dev/null +++ b/Flow.Launcher.Test/ImageLoaderTests.cs @@ -0,0 +1,200 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Image; +using NUnit.Framework; + +namespace Flow.Launcher.Test +{ + [TestFixture] + [NonParallelizable] + public class ImageLoaderTests + { + private Func _originalShellThumbnailLoader; + + private Func _failingShellThumbnailLoader = + (_, _, _) => throw new InvalidOperationException("Forced shell thumbnail failure"); + + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await ImageLoader.InitializeAsync(); + + // Explicitly load defaults by constant keys so fallback tests do not depend on cache preload timing. + // This should be enough for the current test set, but future tests that depend on cache behavior may need DI/injection. + _ = await ImageLoader.LoadAsync(Constant.MissingImgIcon, loadFullImage: false, cacheImage: true); + _ = await ImageLoader.LoadAsync(Constant.FolderIcon, loadFullImage: false, cacheImage: true); + + Assert.That(ImageLoader.MissingImage, Is.Not.Null, "ImageLoader initialization must load default missing image."); + Assert.That(ImageLoader.FolderImage, Is.Not.Null, "ImageLoader initialization must load default folder image."); + } + + [SetUp] + public void SetUp() + { + _originalShellThumbnailLoader = ImageLoader.ShellThumbnailLoader; + } + + [TearDown] + public void TearDown() + { + ImageLoader.ShellThumbnailLoader = _originalShellThumbnailLoader; + } + + #region Missing Image Cases + + [Test] + public async Task NonExistentPath_ReturnsMissingImageAsync() + { + var missingPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.txt"); + + var image = await ImageLoader.LoadAsync(missingPath, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + + [Test] + public async Task NonExistentFolderPath_ReturnsMissingImageAsync() + { + var missingFolderPath = Path.Combine(Path.GetTempPath(), $"missing-folder-{Guid.NewGuid():N}"); + + var image = await ImageLoader.LoadAsync(missingFolderPath, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + + [Test] + public async Task InvalidSvg_ReturnsMissingImageAsync() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"image-loader-{Guid.NewGuid():N}.svg"); + await File.WriteAllTextAsync(tempPath, "not-a-valid-image"); + + try + { + foreach (var loadFullImage in new[] { false, true }) + { + var image = await ImageLoader.LoadAsync(tempPath, loadFullImage, cacheImage: false); + + Assert.That(image, Is.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + #endregion + + #region Shell Thumbnail Failure Fallbacks + + [Test] + public async Task ShellThumbnailFailure_Directory_ReturnsDefaultFolderImageAsync() + { + var tempFolder = Path.Combine(Path.GetTempPath(), $"image-loader-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempFolder); + + try + { + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; + + var image = await ImageLoader.LoadAsync(tempFolder, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.SameAs(ImageLoader.FolderImage)); + Assert.That(image.IsFrozen, Is.True); + } + finally + { + if (Directory.Exists(tempFolder)) + { + Directory.Delete(tempFolder); + } + } + } + + [Test] + public async Task ShellThumbnailFailure_ImageFile_ReturnsNonMissingImageAsync() + { + var defaultIconExtension = Path.GetExtension(Constant.DefaultIcon); + Assert.That(defaultIconExtension, Is.Not.Null.And.Not.Empty, "Default icon must have a file extension."); + Assert.That(string.Equals(defaultIconExtension, ".svg", StringComparison.OrdinalIgnoreCase), Is.False, + "This test covers the non-SVG image-file branch."); + + var tempImagePath = Path.Combine(Path.GetTempPath(), $"image-loader-{Guid.NewGuid():N}{defaultIconExtension}"); + File.Copy(Constant.DefaultIcon, tempImagePath); + + try + { + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; + + var image = await ImageLoader.LoadAsync(tempImagePath, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.Not.Null); + Assert.That(image, Is.Not.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + finally + { + if (File.Exists(tempImagePath)) + { + File.Delete(tempImagePath); + } + } + } + + [Test] + public async Task ShellThumbnailFailure_Executable_ReturnsNonMissingImageAsync() + { + // Use the current process executable as a stable existing generic file input. + var executablePath = Environment.ProcessPath; + Assert.That(executablePath, Is.Not.Null.And.Not.Empty, "Current process executable path is unavailable."); + Assert.That(File.Exists(executablePath), Is.True, $"Current process executable path does not exist: {executablePath}"); + + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; + + var image = await ImageLoader.LoadAsync(executablePath, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.Not.Null); + Assert.That(image, Is.Not.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + + [Test] + public async Task ShellThumbnailFailure_TextFile_ReturnsNonMissingImageAsync() + { + var tempTextPath = Path.Combine(Path.GetTempPath(), $"image-loader-{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(tempTextPath, "fallback-test"); + + try + { + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; + + var image = await ImageLoader.LoadAsync(tempTextPath, loadFullImage: false, cacheImage: false); + + Assert.That(image, Is.Not.Null); + Assert.That(image, Is.Not.SameAs(ImageLoader.MissingImage)); + Assert.That(image.IsFrozen, Is.True); + } + finally + { + if (File.Exists(tempTextPath)) + { + File.Delete(tempTextPath); + } + } + } + + #endregion + } +}