From 14d51b06202a67a8d7665d7ff77f8969b5fb511e Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 17:48:05 +0200 Subject: [PATCH 01/18] Fallback to ExtractAssociatedIcon if GetThumbnail fails on non image/svg files --- .../Image/ImageLoader.cs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 598347fd21f..655474da7be 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; @@ -257,7 +259,24 @@ private static ImageResult GetThumbnailResult(ref string path, bool loadFullImag else { type = ImageType.File; - image = GetThumbnail(path, ThumbnailOptions.None, loadFullImage ? FullIconSize : SmallIconSize); + var size = loadFullImage ? FullIconSize : SmallIconSize; + try + { + image = GetThumbnail(path, ThumbnailOptions.None, size); + } + catch (System.Exception ex) + { + Log.Info(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); + + image = ExtractAssociatedIconOrNull(path, size); + if (image == null) + { + Log.Info(ClassName, $"ExtractAssociatedIcon returned no icon for {path}. Using missing image."); + image = MissingImage; + path = Constant.MissingImgIcon; + type = ImageType.Error; + } + } } } else @@ -284,6 +303,29 @@ private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = option); } + private static BitmapSource ExtractAssociatedIconOrNull(string path, int size) + { + try + { + using var icon = System.Drawing.Icon.ExtractAssociatedIcon(path); + if (icon == null) + { + return null; + } + + var image = Imaging.CreateBitmapSourceFromHIcon( + icon.Handle, + Int32Rect.Empty, + BitmapSizeOptions.FromWidthAndHeight(size, size)); + image.Freeze(); + return image; + } + catch + { + return null; + } + } + public static bool CacheContainImage(string path, bool loadFullImage = false) { return ImageCache.ContainsKey(path, loadFullImage); From 847d5301e88bfe3f37806105e6f96e7e09dede1a Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 18:25:48 +0200 Subject: [PATCH 02/18] Extract bitmap image loading logic to a reusable method --- .../Image/ImageLoader.cs | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 655474da7be..43ac5940e83 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -371,32 +371,15 @@ public static async ValueTask LoadAsync(string path, bool loadFullI private static BitmapImage LoadFullImage(string path) { - BitmapImage image = new BitmapImage(); - image.BeginInit(); - image.CacheOption = BitmapCacheOption.OnLoad; - image.UriSource = new Uri(path); - image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - image.EndInit(); + BitmapImage image = LoadBitmapImage(path); if (image.PixelWidth > FullImageSize) { - BitmapImage resizedWidth = new BitmapImage(); - resizedWidth.BeginInit(); - resizedWidth.CacheOption = BitmapCacheOption.OnLoad; - resizedWidth.UriSource = new Uri(path); - resizedWidth.CreateOptions = BitmapCreateOptions.IgnoreColorProfile; - resizedWidth.DecodePixelWidth = FullImageSize; - resizedWidth.EndInit(); + BitmapImage resizedWidth = LoadBitmapImage(path, decodePixelWidth: FullImageSize); 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(); + BitmapImage resizedHeight = LoadBitmapImage(path, decodePixelHeight: FullImageSize); return resizedHeight; } @@ -406,6 +389,28 @@ private static BitmapImage LoadFullImage(string path) return image; } + 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; + + if (decodePixelWidth.HasValue) + { + image.DecodePixelWidth = decodePixelWidth.Value; + } + + if (decodePixelHeight.HasValue) + { + image.DecodePixelHeight = decodePixelHeight.Value; + } + + image.EndInit(); + return image; + } + private static RenderTargetBitmap LoadSvgImage(string path, bool loadFullImage = false) { // Set up drawing settings From 276be3be185b447fddad37489fdebe0402ed1649 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 18:43:31 +0200 Subject: [PATCH 03/18] Rename LoadFullImage to LoadBitmapImageScaleToFitWithin and add max size parameter This makes it more reusable and removes the hidden dependency on FullImageSize --- Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 43ac5940e83..70c5a768af7 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -222,7 +222,7 @@ private static ImageResult GetThumbnailResult(ref string path, bool loadFullImag { try { - image = LoadFullImage(path); + image = LoadBitmapImageScaleToFitWithin(path, FullImageSize); type = ImageType.FullImageFile; } catch (NotSupportedException ex) @@ -369,17 +369,17 @@ public static async ValueTask LoadAsync(string path, bool loadFullI return img; } - private static BitmapImage LoadFullImage(string path) + private static BitmapImage LoadBitmapImageScaleToFitWithin(string path, int maxSize) { BitmapImage image = LoadBitmapImage(path); - if (image.PixelWidth > FullImageSize) + if (image.PixelWidth > maxSize) { - BitmapImage resizedWidth = LoadBitmapImage(path, decodePixelWidth: FullImageSize); + BitmapImage resizedWidth = LoadBitmapImage(path, decodePixelWidth: maxSize); - if (resizedWidth.PixelHeight > FullImageSize) + if (resizedWidth.PixelHeight > maxSize) { - BitmapImage resizedHeight = LoadBitmapImage(path, decodePixelHeight: FullImageSize); + BitmapImage resizedHeight = LoadBitmapImage(path, decodePixelHeight: maxSize); return resizedHeight; } From baeaaa9c8b16fae5297957b51767d7d1ecb2a944 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 19:15:15 +0200 Subject: [PATCH 04/18] Fallback to loading the bitmap image at small size if GetThumbnail fails --- .../Image/ImageLoader.cs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 70c5a768af7..566f3bb1b88 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -234,12 +234,30 @@ private static ImageResult GetThumbnailResult(ref string path, bool loadFullImag } 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); + 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 + */ + image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); + } + catch (System.Exception ex) + { + Log.Info(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); + + try + { + image = LoadBitmapImageScaleToFitWithin(path, SmallIconSize); + } + catch (System.Exception ex2) + { + image = Image; + type = ImageType.Error; + Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); + } + } } } else if (extension == SvgExtension) From e395fcc671a01a16d963d6f987867ec8a2aabd76 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 20:13:00 +0200 Subject: [PATCH 05/18] Fallback to default folder icon if GetThumbnail fails on folders --- Flow.Launcher.Infrastructure/Constant.cs | 1 + .../Image/ImageLoader.cs | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 94aa42fea6a..3250f16bb09 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -35,6 +35,7 @@ public static class Constant 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 566f3bb1b88..a96d09d4569 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -29,6 +29,7 @@ public static class ImageLoader 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; @@ -48,7 +49,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.ImageIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon, Constant.FolderIcon }) { ImageSource img = new BitmapImage(new Uri(icon)); img.Freeze(); @@ -204,13 +205,23 @@ private static ImageResult GetThumbnailResult(ref string path, bool loadFullImag if (Directory.Exists(path)) { - /* 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); + 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); + } + catch (System.Exception ex) + { + Log.Info(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); + type = ImageType.Folder; + image = FolderImage; + } + } else if (File.Exists(path)) { From e77b6e8b0cbfa059159c79b892c46c9886526db1 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 22:01:56 +0200 Subject: [PATCH 06/18] Refactor GetThumbnailResult by extracting branch cases into helper methods --- .../Image/ImageLoader.cs | 208 +++++++++--------- 1 file changed, 108 insertions(+), 100 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index a96d09d4569..b21db2c19b6 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -200,126 +200,134 @@ private static async Task LoadRemoteImageAsync(bool loadFullImage, private static ImageResult GetThumbnailResult(ref string path, bool loadFullImage = false) { - ImageSource image; - ImageType type = ImageType.Error; - if (Directory.Exists(path)) + return GetDirectoryThumbnailResult(path); + + if (!File.Exists(path)) + return GetMissingThumbnailResult(ref path); + + var extension = Path.GetExtension(path).ToLower(); + + if (ImageExtensions.Contains(extension)) + return GetImageFileThumbnailResult(path, loadFullImage); + + if (extension == SvgExtension) + return GetSvgFileThumbnailResult(path, loadFullImage); + + return GetFileThumbnailResult(ref 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(ref string path) + { + path = Constant.MissingImgIcon; + return CreateImageResult(MissingImage, ImageType.Error); + } + + private static ImageResult GetDirectoryThumbnailResult(string path) + { + 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 + */ + var image = GetThumbnail(path, ThumbnailOptions.IconOnly); + return CreateImageResult(image, ImageType.Folder); + } + catch (System.Exception ex) + { + Log.Info(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 { - /* 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 = LoadBitmapImageScaleToFitWithin(path, FullImageSize); + return CreateImageResult(image, ImageType.FullImageFile); } - catch (System.Exception ex) + catch (NotSupportedException ex) { - Log.Info(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); - type = ImageType.Folder; - image = FolderImage; + Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); + return CreateImageResult(Image, ImageType.Error); } - } - else if (File.Exists(path)) + + try { - var extension = Path.GetExtension(path).ToLower(); - if (ImageExtensions.Contains(extension)) - { - type = ImageType.ImageFile; - if (loadFullImage) - { - try - { - image = LoadBitmapImageScaleToFitWithin(path, FullImageSize); - 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 - { - 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 - */ - image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); - } - catch (System.Exception ex) - { - Log.Info(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); - - try - { - image = LoadBitmapImageScaleToFitWithin(path, SmallIconSize); - } - catch (System.Exception ex2) - { - image = Image; - type = ImageType.Error; - Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); - } - } - } - } - else if (extension == SvgExtension) + /* 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 = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); + return CreateImageResult(image, ImageType.ImageFile); + } + catch (System.Exception ex) + { + Log.Info(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); + + try { - 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); - } + var image = LoadBitmapImageScaleToFitWithin(path, SmallIconSize); + return CreateImageResult(image, ImageType.ImageFile); } - else + catch (System.Exception ex2) { - type = ImageType.File; - var size = loadFullImage ? FullIconSize : SmallIconSize; - try - { - image = GetThumbnail(path, ThumbnailOptions.None, size); - } - catch (System.Exception ex) - { - Log.Info(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); - - image = ExtractAssociatedIconOrNull(path, size); - if (image == null) - { - Log.Info(ClassName, $"ExtractAssociatedIcon returned no icon for {path}. Using missing image."); - image = MissingImage; - path = Constant.MissingImgIcon; - type = ImageType.Error; - } - } + Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); + return CreateImageResult(Image, ImageType.Error); } } - 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.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); + return CreateImageResult(Image, ImageType.Error); } + } - if (type != ImageType.Error) + private static ImageResult GetFileThumbnailResult(ref string path, bool loadFullImage) + { + var size = loadFullImage ? FullIconSize : SmallIconSize; + try { - image.Freeze(); + var image = GetThumbnail(path, ThumbnailOptions.None, size); + return CreateImageResult(image, ImageType.File); } + catch (System.Exception ex) + { + Log.Info(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); - return new ImageResult(image, type); + var image = ExtractAssociatedIconOrNull(path, size); + if (image != null) + return CreateImageResult(image, ImageType.File); + + Log.Info(ClassName, $"ExtractAssociatedIcon returned no icon for {path}. Using missing image."); + return GetMissingThumbnailResult(ref path); + } } private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, From e2ee779d7cf73db220a326dc7d6731c87a1757f3 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 22:51:48 +0200 Subject: [PATCH 07/18] Consistently use MissingImage instead of generic Image for missing thumbnail icon --- Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index b21db2c19b6..b3640c9e29a 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -26,7 +26,6 @@ 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]; @@ -209,10 +208,10 @@ private static ImageResult GetThumbnailResult(ref string path, bool loadFullImag var extension = Path.GetExtension(path).ToLower(); if (ImageExtensions.Contains(extension)) - return GetImageFileThumbnailResult(path, loadFullImage); + return GetImageFileThumbnailResult(ref path, loadFullImage); if (extension == SvgExtension) - return GetSvgFileThumbnailResult(path, loadFullImage); + return GetSvgFileThumbnailResult(ref path, loadFullImage); return GetFileThumbnailResult(ref path, loadFullImage); } @@ -252,7 +251,7 @@ private static ImageResult GetDirectoryThumbnailResult(string path) } } - private static ImageResult GetImageFileThumbnailResult(string path, bool loadFullImage) + private static ImageResult GetImageFileThumbnailResult(ref string path, bool loadFullImage) { if (loadFullImage) { @@ -264,7 +263,7 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul catch (NotSupportedException ex) { Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); - return CreateImageResult(Image, ImageType.Error); + return GetMissingThumbnailResult(ref path); } } @@ -290,12 +289,12 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul catch (System.Exception ex2) { Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); - return CreateImageResult(Image, ImageType.Error); + return GetMissingThumbnailResult(ref path); } } } - private static ImageResult GetSvgFileThumbnailResult(string path, bool loadFullImage) + private static ImageResult GetSvgFileThumbnailResult(ref string path, bool loadFullImage) { try { @@ -305,7 +304,7 @@ private static ImageResult GetSvgFileThumbnailResult(string path, bool loadFullI catch (System.Exception ex) { Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); - return CreateImageResult(Image, ImageType.Error); + return GetMissingThumbnailResult(ref path); } } From 4989f89266f79bdf62e7f786c4d86f9093af9ca1 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 10 May 2026 22:53:32 +0200 Subject: [PATCH 08/18] Make directory fallback to folder icon also modify path to it This makes it consistent with the missing icon fallback --- Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index b3640c9e29a..187b4308609 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -200,7 +200,7 @@ private static async Task LoadRemoteImageAsync(bool loadFullImage, private static ImageResult GetThumbnailResult(ref string path, bool loadFullImage = false) { if (Directory.Exists(path)) - return GetDirectoryThumbnailResult(path); + return GetDirectoryThumbnailResult(ref path); if (!File.Exists(path)) return GetMissingThumbnailResult(ref path); @@ -232,7 +232,7 @@ private static ImageResult GetMissingThumbnailResult(ref string path) return CreateImageResult(MissingImage, ImageType.Error); } - private static ImageResult GetDirectoryThumbnailResult(string path) + private static ImageResult GetDirectoryThumbnailResult(ref string path) { try { @@ -247,6 +247,7 @@ private static ImageResult GetDirectoryThumbnailResult(string path) catch (System.Exception ex) { Log.Info(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); + path = Constant.FolderIcon; return CreateImageResult(FolderImage, ImageType.Folder); } } From 34b217c6522a88e5bbeb10ee248de0b748e88794 Mon Sep 17 00:00:00 2001 From: David Brett Date: Mon, 11 May 2026 10:33:40 +0200 Subject: [PATCH 09/18] Remove unused ImageIcon constant --- Flow.Launcher.Infrastructure/Constant.cs | 1 - Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Constant.cs b/Flow.Launcher.Infrastructure/Constant.cs index 3250f16bb09..1335609001f 100644 --- a/Flow.Launcher.Infrastructure/Constant.cs +++ b/Flow.Launcher.Infrastructure/Constant.cs @@ -32,7 +32,6 @@ 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"); diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 187b4308609..b7ea0039073 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -48,7 +48,7 @@ public static async Task InitializeAsync() ImageCache.Initialize(usage); - foreach (var icon in new[] { Constant.DefaultIcon, Constant.ImageIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon, Constant.FolderIcon }) + foreach (var icon in new[] { Constant.DefaultIcon, Constant.MissingImgIcon, Constant.LoadingImgIcon, Constant.FolderIcon }) { ImageSource img = new BitmapImage(new Uri(icon)); img.Freeze(); From 74154f075480d46a8819d5ca1290bd8a816414c5 Mon Sep 17 00:00:00 2001 From: David Brett Date: Mon, 11 May 2026 10:43:11 +0200 Subject: [PATCH 10/18] Fix bitmap image scaling to fit logic --- .../Image/ImageLoader.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index b7ea0039073..3345c63a8c9 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -410,20 +410,20 @@ private static BitmapImage LoadBitmapImageScaleToFitWithin(string path, int maxS { BitmapImage image = LoadBitmapImage(path); - if (image.PixelWidth > maxSize) - { - BitmapImage resizedWidth = LoadBitmapImage(path, decodePixelWidth: maxSize); + if (image.PixelWidth <= maxSize && image.PixelHeight <= maxSize) + return image; - if (resizedWidth.PixelHeight > maxSize) - { - BitmapImage resizedHeight = LoadBitmapImage(path, decodePixelHeight: maxSize); - return resizedHeight; - } + bool widthIsLarger = image.PixelWidth >= image.PixelHeight; - return resizedWidth; + // 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); } - - return image; } private static BitmapImage LoadBitmapImage(string path, int? decodePixelWidth = null, int? decodePixelHeight = null) From e3fa2a4507291e07ae92a98b80082fd4121b57be Mon Sep 17 00:00:00 2001 From: David Brett Date: Mon, 11 May 2026 12:24:05 +0200 Subject: [PATCH 11/18] Refactor ImageLoader to remove path mutation side effects - Remove ref path parameters from thumbnail helper methods. - Stop rewriting path to missing/folder icon constants in fallback paths. This eliminates hidden side effects and make helper flow easier to reason about. Mutation previously only affected rare final-error logging/cache paths, which seemed like unintended behavior there. Now we will always log the original path and cache it to the missing image instead of pointlessly caching the missing image to the missing image path. --- .../Image/ImageLoader.cs | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 3345c63a8c9..a83c3b65981 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -151,14 +151,14 @@ 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) { @@ -197,23 +197,23 @@ 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) { if (Directory.Exists(path)) - return GetDirectoryThumbnailResult(ref path); + return GetDirectoryThumbnailResult(path); if (!File.Exists(path)) - return GetMissingThumbnailResult(ref path); + return GetMissingThumbnailResult(); var extension = Path.GetExtension(path).ToLower(); if (ImageExtensions.Contains(extension)) - return GetImageFileThumbnailResult(ref path, loadFullImage); + return GetImageFileThumbnailResult(path, loadFullImage); if (extension == SvgExtension) - return GetSvgFileThumbnailResult(ref path, loadFullImage); + return GetSvgFileThumbnailResult(path, loadFullImage); - return GetFileThumbnailResult(ref path, loadFullImage); + return GetFileThumbnailResult(path, loadFullImage); } private static ImageResult CreateImageResult(ImageSource image, ImageType type) @@ -226,13 +226,12 @@ private static ImageResult CreateImageResult(ImageSource image, ImageType type) return new ImageResult(image, type); } - private static ImageResult GetMissingThumbnailResult(ref string path) + private static ImageResult GetMissingThumbnailResult() { - path = Constant.MissingImgIcon; return CreateImageResult(MissingImage, ImageType.Error); } - private static ImageResult GetDirectoryThumbnailResult(ref string path) + private static ImageResult GetDirectoryThumbnailResult(string path) { try { @@ -247,12 +246,11 @@ private static ImageResult GetDirectoryThumbnailResult(ref string path) catch (System.Exception ex) { Log.Info(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); - path = Constant.FolderIcon; return CreateImageResult(FolderImage, ImageType.Folder); } } - private static ImageResult GetImageFileThumbnailResult(ref string path, bool loadFullImage) + private static ImageResult GetImageFileThumbnailResult(string path, bool loadFullImage) { if (loadFullImage) { @@ -264,7 +262,7 @@ private static ImageResult GetImageFileThumbnailResult(ref string path, bool loa catch (NotSupportedException ex) { Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex.Message}", ex); - return GetMissingThumbnailResult(ref path); + return GetMissingThumbnailResult(); } } @@ -290,12 +288,12 @@ private static ImageResult GetImageFileThumbnailResult(ref string path, bool loa catch (System.Exception ex2) { Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); - return GetMissingThumbnailResult(ref path); + return GetMissingThumbnailResult(); } } } - private static ImageResult GetSvgFileThumbnailResult(ref string path, bool loadFullImage) + private static ImageResult GetSvgFileThumbnailResult(string path, bool loadFullImage) { try { @@ -305,11 +303,11 @@ private static ImageResult GetSvgFileThumbnailResult(ref string path, bool loadF catch (System.Exception ex) { Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); - return GetMissingThumbnailResult(ref path); + return GetMissingThumbnailResult(); } } - private static ImageResult GetFileThumbnailResult(ref string path, bool loadFullImage) + private static ImageResult GetFileThumbnailResult(string path, bool loadFullImage) { var size = loadFullImage ? FullIconSize : SmallIconSize; try @@ -326,7 +324,7 @@ private static ImageResult GetFileThumbnailResult(ref string path, bool loadFull return CreateImageResult(image, ImageType.File); Log.Info(ClassName, $"ExtractAssociatedIcon returned no icon for {path}. Using missing image."); - return GetMissingThumbnailResult(ref path); + return GetMissingThumbnailResult(); } } From a4181caa87b5bc0a650260ff12b1805648a46565 Mon Sep 17 00:00:00 2001 From: David Brett Date: Wed, 13 May 2026 23:03:48 +0200 Subject: [PATCH 12/18] Add and use TryGetBitmapImageDimensionsFromMetadata to avoid unnecessary image decoding when checking if resizing is required --- .../Image/ImageLoader.cs | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index a83c3b65981..db59de9603f 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -404,14 +404,56 @@ public static async ValueTask LoadAsync(string path, bool loadFullI return img; } + 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 image = LoadBitmapImage(path); + BitmapImage decodedImage = null; - if (image.PixelWidth <= maxSize && image.PixelHeight <= maxSize) - return image; + // 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 = image.PixelWidth >= image.PixelHeight; + bool widthIsLarger = width >= height; // LoadBitmapImage will maintain aspect ratio so we only need to scale by the largest dimension if (widthIsLarger) From d989b2ee4a35b8dd6836431c249e72712252b16c Mon Sep 17 00:00:00 2001 From: David Brett Date: Fri, 15 May 2026 17:23:27 +0200 Subject: [PATCH 13/18] Use better Log levels and improve messages in ImageLoader - Use Log.Warn instead of Log.Exception or others for the thumbnail failures - Use Log.Debug for intermediate fallback logging. - Improve log messages to consistently mention if we use a fallback icon. --- .../Image/ImageLoader.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index db59de9603f..f07f0b20512 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -162,8 +162,8 @@ private static async ValueTask LoadInternalAsync(string path, bool } 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; @@ -245,7 +245,7 @@ private static ImageResult GetDirectoryThumbnailResult(string path) } catch (System.Exception ex) { - Log.Info(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); + Log.Warn(ClassName, $"Failed to get shell thumbnail for folder {path}: {ex.Message}\nUsing default folder image as fallback."); return CreateImageResult(FolderImage, ImageType.Folder); } } @@ -261,7 +261,7 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul } catch (NotSupportedException ex) { - Log.Exception(ClassName, $"Failed to load image file 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(); } } @@ -278,7 +278,7 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul } catch (System.Exception ex) { - Log.Info(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); + Log.Debug(ClassName, $"Failed to get shell thumbnail for image file {path}: {ex.Message}\nTrying bitmap fallback."); try { @@ -287,7 +287,7 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul } catch (System.Exception ex2) { - Log.Exception(ClassName, $"Failed to load image file from path {path}: {ex2.Message}", ex2); + Log.Warn(ClassName, $"Failed to load image file from path {path}: {ex2.Message}\nUsing missing icon instead."); return GetMissingThumbnailResult(); } } @@ -302,7 +302,7 @@ private static ImageResult GetSvgFileThumbnailResult(string path, bool loadFullI } catch (System.Exception ex) { - Log.Exception(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}", ex); + Log.Warn(ClassName, $"Failed to load SVG image from path {path}: {ex.Message}\nUsing missing icon instead."); return GetMissingThumbnailResult(); } } @@ -317,13 +317,13 @@ private static ImageResult GetFileThumbnailResult(string path, bool loadFullImag } catch (System.Exception ex) { - Log.Info(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); + Log.Debug(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); var image = ExtractAssociatedIconOrNull(path, size); if (image != null) return CreateImageResult(image, ImageType.File); - Log.Info(ClassName, $"ExtractAssociatedIcon returned no icon for {path}. Using missing image."); + Log.Warn(ClassName, $"ExtractAssociatedIcon returned no icon for path: {path}\nUsing missing icon instead."); return GetMissingThumbnailResult(); } } From 209fcd20e6a708ff0605d9bec8bb885bfb80b074 Mon Sep 17 00:00:00 2001 From: David Brett Date: Fri, 15 May 2026 17:30:39 +0200 Subject: [PATCH 14/18] Make GetDirectoryThumbnailResult take loadFullImage flag like the other thumbnail handlers This is not the original behavior but is consistent with the other branches, and makes sense to provide this as an option --- Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index f07f0b20512..930e913fec6 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -200,7 +200,7 @@ private static async Task LoadRemoteImageAsync(bool loadFullImage, private static ImageResult GetThumbnailResult(string path, bool loadFullImage = false) { if (Directory.Exists(path)) - return GetDirectoryThumbnailResult(path); + return GetDirectoryThumbnailResult(path, loadFullImage); if (!File.Exists(path)) return GetMissingThumbnailResult(); @@ -231,8 +231,9 @@ private static ImageResult GetMissingThumbnailResult() return CreateImageResult(MissingImage, ImageType.Error); } - private static ImageResult GetDirectoryThumbnailResult(string path) + private static ImageResult GetDirectoryThumbnailResult(string path, bool loadFullImage) { + var size = loadFullImage ? FullIconSize : SmallIconSize; try { /* Directories can also have thumbnails instead of shell icons. @@ -240,7 +241,7 @@ private static ImageResult GetDirectoryThumbnailResult(string path) * could have a big impact on performance and Flow.Launcher responsibility. * - Solution: just load the icon */ - var image = GetThumbnail(path, ThumbnailOptions.IconOnly); + var image = GetThumbnail(path, ThumbnailOptions.IconOnly, size); return CreateImageResult(image, ImageType.Folder); } catch (System.Exception ex) From 130b729ce6874cf8fc1aba082c0040e8b946de02 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sat, 16 May 2026 20:19:29 +0200 Subject: [PATCH 15/18] Replace ExtractAssociatedIconOrNull with TryExtractAssociatedIcon Makes success or failure more explicit and simplifies use Also more consistent with other try-style methods for a similar purpose such as TryGetBitmapImageDimensionsFromMetadata --- .../Image/ImageLoader.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 930e913fec6..04349ad63ab 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -320,9 +320,10 @@ private static ImageResult GetFileThumbnailResult(string path, bool loadFullImag { Log.Debug(ClassName, $"Failed to get shell thumbnail for {path}: {ex.Message}\nTrying ExtractAssociatedIcon fallback."); - var image = ExtractAssociatedIconOrNull(path, size); - if (image != null) + 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(); @@ -339,26 +340,29 @@ private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = option); } - private static BitmapSource ExtractAssociatedIconOrNull(string path, int 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 null; + return false; } - var image = Imaging.CreateBitmapSourceFromHIcon( + image = Imaging.CreateBitmapSourceFromHIcon( icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromWidthAndHeight(size, size)); image.Freeze(); - return image; + return true; } catch { - return null; + image = null; + return false; } } From fc263dc1e3430e405c57534c194d5bf75a4fd905 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sat, 23 May 2026 13:28:56 +0200 Subject: [PATCH 16/18] Add ThumbnailLoader delegate for GetThumbnail testability --- Flow.Launcher.Infrastructure/Image/ImageLoader.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 04349ad63ab..897e466bda5 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -35,6 +35,13 @@ public static class ImageLoader private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico"]; private static readonly string SvgExtension = ".svg"; + internal static Func ThumbnailLoader { get; set; } = + (path, option, size) => + WindowsThumbnailProvider.GetThumbnail( + path, + size, + size, + option); public static async Task InitializeAsync() { @@ -333,11 +340,7 @@ private static ImageResult GetFileThumbnailResult(string path, bool loadFullImag private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, int size = SmallIconSize) { - return WindowsThumbnailProvider.GetThumbnail( - path, - size, - size, - option); + return ThumbnailLoader(path, option, size); } private static bool TryExtractAssociatedIcon(string path, int size, out BitmapSource image) From 6c973088e3650708a76716d892cff4fa9be6d342 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sat, 23 May 2026 17:40:36 +0200 Subject: [PATCH 17/18] Add test suite for ImageLoader Covers the new fallbacks if shell thumbnail fails and the simple cases where a missing image be returned --- Flow.Launcher.Test/Flow.Launcher.Test.csproj | 6 + Flow.Launcher.Test/ImageLoaderTests.cs | 200 +++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 Flow.Launcher.Test/ImageLoaderTests.cs 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..a5c1e4d582f --- /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 _originalThumbnailLoader; + + private Func _failingThumbnailLoader = + (_, _, _) => 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() + { + _originalThumbnailLoader = ImageLoader.ThumbnailLoader; + } + + [TearDown] + public void TearDown() + { + ImageLoader.ThumbnailLoader = _originalThumbnailLoader; + } + + #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.ThumbnailLoader = _failingThumbnailLoader; + + 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.ThumbnailLoader = _failingThumbnailLoader; + + 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.ThumbnailLoader = _failingThumbnailLoader; + + 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.ThumbnailLoader = _failingThumbnailLoader; + + 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 + } +} From ccda60af809ac7873db839ffd595e79112f47e6a Mon Sep 17 00:00:00 2001 From: David Brett Date: Sat, 23 May 2026 17:48:26 +0200 Subject: [PATCH 18/18] Rename GetThumbnail to GetShellThumbnail and the same to derived variables and fields --- .../Image/ImageLoader.cs | 12 ++++++------ Flow.Launcher.Test/ImageLoaderTests.cs | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs index 897e466bda5..83501b16fe2 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageLoader.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageLoader.cs @@ -35,7 +35,7 @@ public static class ImageLoader private static readonly string[] ImageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico"]; private static readonly string SvgExtension = ".svg"; - internal static Func ThumbnailLoader { get; set; } = + internal static Func ShellThumbnailLoader { get; set; } = (path, option, size) => WindowsThumbnailProvider.GetThumbnail( path, @@ -248,7 +248,7 @@ private static ImageResult GetDirectoryThumbnailResult(string path, bool loadFul * could have a big impact on performance and Flow.Launcher responsibility. * - Solution: just load the icon */ - var image = GetThumbnail(path, ThumbnailOptions.IconOnly, size); + var image = GetShellThumbnail(path, ThumbnailOptions.IconOnly, size); return CreateImageResult(image, ImageType.Folder); } catch (System.Exception ex) @@ -281,7 +281,7 @@ private static ImageResult GetImageFileThumbnailResult(string path, bool loadFul * be the case in many situations while testing. * - Solution: explicitly pass the ThumbnailOnly flag */ - var image = GetThumbnail(path, ThumbnailOptions.ThumbnailOnly); + var image = GetShellThumbnail(path, ThumbnailOptions.ThumbnailOnly); return CreateImageResult(image, ImageType.ImageFile); } catch (System.Exception ex) @@ -320,7 +320,7 @@ private static ImageResult GetFileThumbnailResult(string path, bool loadFullImag var size = loadFullImage ? FullIconSize : SmallIconSize; try { - var image = GetThumbnail(path, ThumbnailOptions.None, size); + var image = GetShellThumbnail(path, ThumbnailOptions.None, size); return CreateImageResult(image, ImageType.File); } catch (System.Exception ex) @@ -337,10 +337,10 @@ private static ImageResult GetFileThumbnailResult(string path, bool loadFullImag } } - private static BitmapSource GetThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, + private static BitmapSource GetShellThumbnail(string path, ThumbnailOptions option = ThumbnailOptions.ThumbnailOnly, int size = SmallIconSize) { - return ThumbnailLoader(path, option, size); + return ShellThumbnailLoader(path, option, size); } private static bool TryExtractAssociatedIcon(string path, int size, out BitmapSource image) diff --git a/Flow.Launcher.Test/ImageLoaderTests.cs b/Flow.Launcher.Test/ImageLoaderTests.cs index a5c1e4d582f..155746856b1 100644 --- a/Flow.Launcher.Test/ImageLoaderTests.cs +++ b/Flow.Launcher.Test/ImageLoaderTests.cs @@ -14,9 +14,9 @@ namespace Flow.Launcher.Test [NonParallelizable] public class ImageLoaderTests { - private Func _originalThumbnailLoader; + private Func _originalShellThumbnailLoader; - private Func _failingThumbnailLoader = + private Func _failingShellThumbnailLoader = (_, _, _) => throw new InvalidOperationException("Forced shell thumbnail failure"); @@ -37,13 +37,13 @@ public async Task OneTimeSetUpAsync() [SetUp] public void SetUp() { - _originalThumbnailLoader = ImageLoader.ThumbnailLoader; + _originalShellThumbnailLoader = ImageLoader.ShellThumbnailLoader; } [TearDown] public void TearDown() { - ImageLoader.ThumbnailLoader = _originalThumbnailLoader; + ImageLoader.ShellThumbnailLoader = _originalShellThumbnailLoader; } #region Missing Image Cases @@ -107,7 +107,7 @@ public async Task ShellThumbnailFailure_Directory_ReturnsDefaultFolderImageAsync try { - ImageLoader.ThumbnailLoader = _failingThumbnailLoader; + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; var image = await ImageLoader.LoadAsync(tempFolder, loadFullImage: false, cacheImage: false); @@ -136,7 +136,7 @@ public async Task ShellThumbnailFailure_ImageFile_ReturnsNonMissingImageAsync() try { - ImageLoader.ThumbnailLoader = _failingThumbnailLoader; + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; var image = await ImageLoader.LoadAsync(tempImagePath, loadFullImage: false, cacheImage: false); @@ -161,7 +161,7 @@ public async Task ShellThumbnailFailure_Executable_ReturnsNonMissingImageAsync() 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.ThumbnailLoader = _failingThumbnailLoader; + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; var image = await ImageLoader.LoadAsync(executablePath, loadFullImage: false, cacheImage: false); @@ -178,7 +178,7 @@ public async Task ShellThumbnailFailure_TextFile_ReturnsNonMissingImageAsync() try { - ImageLoader.ThumbnailLoader = _failingThumbnailLoader; + ImageLoader.ShellThumbnailLoader = _failingShellThumbnailLoader; var image = await ImageLoader.LoadAsync(tempTextPath, loadFullImage: false, cacheImage: false);