diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 9d6188fa24..1f450a10e6 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -130,6 +130,7 @@ bezelled bhid BIF bigbar +BIGGERSIZEOK bigobj binlog binres @@ -311,6 +312,7 @@ CRECT CRH critsec cropandlock +CROPTOSQUARE Crossdevice csdevkit CSearch @@ -761,6 +763,7 @@ IAI icf ICONERROR ICONLOCATION +ICONONLY IDCANCEL IDD idk @@ -1672,6 +1675,7 @@ sigdn Signedness SIGNINGSCENARIO signtool +SIIGBF SINGLEKEY sipolicy SIZEBOX diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs index 14f9597418..7b111c922b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs @@ -33,6 +33,8 @@ public sealed class AppItem public string? FullExecutablePath { get; set; } + public string? JumboIconPath { get; set; } + public AppItem() { } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index d907277ddc..8d1a05d641 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using ManagedCommon; using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Ext.Apps.Commands; +using Microsoft.CmdPal.Ext.Apps.Helpers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -17,6 +18,7 @@ public sealed partial class AppListItem : ListItem { private readonly AppCommand _appCommand; private readonly AppItem _app; + private readonly Lazy> _iconLoadTask; private readonly Lazy> _detailsLoadTask; @@ -66,7 +68,7 @@ public sealed partial class AppListItem : ListItem MoreCommands = AddPinCommands(_app.Commands!, isPinned); _detailsLoadTask = new Lazy>(BuildDetails); - _iconLoadTask = new Lazy>(async () => await FetchIcon(useThumbnails)); + _iconLoadTask = new Lazy>(async () => await FetchIcon(useThumbnails).ConfigureAwait(false)); } private async Task LoadDetailsAsync() @@ -85,7 +87,7 @@ public sealed partial class AppListItem : ListItem { try { - Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon; + Icon = _appCommand.Icon = CoalesceIcon(await _iconLoadTask.Value); } catch (Exception ex) { @@ -93,6 +95,21 @@ public sealed partial class AppListItem : ListItem } } + private static IconInfo CoalesceIcon(IconInfo? value) + { + return CoalesceIcon(value, Icons.GenericAppIcon)!; + } + + private static IconInfo? CoalesceIcon(IconInfo? value, IconInfo? replacement) + { + return IconIsNullOrEmpty(value) ? replacement : value; + } + + private static bool IconIsNullOrEmpty(IconInfo? value) + { + return value == null || (string.IsNullOrEmpty(value.Light?.Icon) && value.Light?.Data is null) || (string.IsNullOrEmpty(value.Dark?.Icon) && value.Dark?.Data is null); + } + private async Task
BuildDetails() { // Build metadata, with app type, path, etc. @@ -107,24 +124,49 @@ public sealed partial class AppListItem : ListItem metadata.Add(new DetailsElement() { Key = "[DEBUG] AppIdentifier", Data = new DetailsLink() { Text = _app.AppIdentifier } }); metadata.Add(new DetailsElement() { Key = "[DEBUG] ExePath", Data = new DetailsLink() { Text = _app.ExePath } }); metadata.Add(new DetailsElement() { Key = "[DEBUG] IcoPath", Data = new DetailsLink() { Text = _app.IcoPath } }); + metadata.Add(new DetailsElement() { Key = "[DEBUG] JumboIconPath", Data = new DetailsLink() { Text = _app.JumboIconPath ?? "(null)" } }); #endif // Icon IconInfo? heroImage = null; if (_app.IsPackaged) { - heroImage = new IconInfo(_app.IcoPath); + heroImage = new IconInfo(_app.JumboIconPath ?? _app.IcoPath); } else { + // Get the icon from the system + if (!string.IsNullOrEmpty(_app.JumboIconPath)) + { + var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.JumboIconPath, 64); + if (randomAccessStream != null) + { + heroImage = IconInfo.FromStream(randomAccessStream); + } + } + + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath)) + { + var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.IcoPath, 64); + if (randomAccessStream != null) + { + heroImage = IconInfo.FromStream(randomAccessStream); + } + } + // do nothing if we fail to load an icon. // Logging it would be too NOISY, there's really no need. - if (!string.IsNullOrEmpty(_app.IcoPath)) + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.JumboIconPath)) + { + heroImage = await TryLoadThumbnail(_app.JumboIconPath, jumbo: true, logOnFailure: false); + } + + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath)) { heroImage = await TryLoadThumbnail(_app.IcoPath, jumbo: true, logOnFailure: false); } - if (heroImage == null && !string.IsNullOrEmpty(_app.ExePath)) + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.ExePath)) { heroImage = await TryLoadThumbnail(_app.ExePath, jumbo: true, logOnFailure: false); } @@ -133,8 +175,8 @@ public sealed partial class AppListItem : ListItem return new Details() { Title = this.Title, - HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon, - Metadata = metadata.ToArray(), + HeroImage = CoalesceIcon(CoalesceIcon(heroImage, this.Icon as IconInfo)), + Metadata = [..metadata], }; } @@ -154,7 +196,7 @@ public sealed partial class AppListItem : ListItem icon = await TryLoadThumbnail(_app.IcoPath, jumbo: false, logOnFailure: true); } - if (icon == null && !string.IsNullOrEmpty(_app.ExePath)) + if (IconIsNullOrEmpty(icon) && !string.IsNullOrEmpty(_app.ExePath)) { icon = await TryLoadThumbnail(_app.ExePath, jumbo: false, logOnFailure: true); } @@ -196,22 +238,25 @@ public sealed partial class AppListItem : ListItem private async Task TryLoadThumbnail(string path, bool jumbo, bool logOnFailure) { - try + return await Task.Run(async () => { - var stream = await ThumbnailHelper.GetThumbnail(path, jumbo); - if (stream is not null) + try { - return IconInfo.FromStream(stream); + var stream = await ThumbnailHelper.GetThumbnail(path, jumbo).ConfigureAwait(false); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } } - } - catch (Exception ex) - { - if (logOnFailure) + catch (Exception ex) { - Logger.LogDebug($"Failed to load icon {path} for {AppIdentifier}:\n{ex}"); + if (logOnFailure) + { + Logger.LogDebug($"Failed to load icon {path} for {AppIdentifier}:\n{ex}"); + } } - } - return null; + return null; + }).ConfigureAwait(false); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs new file mode 100644 index 0000000000..589cff5214 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Utils; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +internal static class AppxIconLoader +{ + private const string ContrastWhite = "contrast-white"; + private const string ContrastBlack = "contrast-black"; + + private static readonly Dictionary> _scaleFactors = new() + { + { UWP.PackageVersion.Windows10, [100, 125, 150, 200, 400] }, + { UWP.PackageVersion.Windows81, [100, 120, 140, 160, 180] }, + { UWP.PackageVersion.Windows8, [100] }, + }; + + private static readonly List TargetSizes = [16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256]; + + private static IconSearchResult GetScaleIcons( + string path, + string colorscheme, + UWP.PackageVersion packageVersion, + bool highContrast = false) + { + var extension = Path.GetExtension(path); + if (extension is null) + { + return IconSearchResult.NotFound(); + } + + var end = path.Length - extension.Length; + var prefix = path[..end]; + + if (!_scaleFactors.TryGetValue(packageVersion, out var factors)) + { + return IconSearchResult.NotFound(); + } + + var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored; + + // Check from highest scale factor to lowest for best quality + for (var i = factors.Count - 1; i >= 0; i--) + { + var factor = factors[i]; + string[] pathsToTry = highContrast + ? + [ + $"{prefix}.scale-{factor}_{colorscheme}{extension}", + $"{prefix}.{colorscheme}_scale-{factor}{extension}", + ] + : + [ + $"{prefix}.scale-{factor}{extension}", + ]; + + foreach (var p in pathsToTry) + { + if (File.Exists(p)) + { + return IconSearchResult.FoundScaled(p, logoType); + } + } + } + + // Check base path (100% scale) as last resort + if (!highContrast && File.Exists(path)) + { + return IconSearchResult.FoundScaled(path, logoType); + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult GetTargetSizeIcon( + string path, + string colorscheme, + bool highContrast = false, + int appIconSize = 36, + double maxSizeCoefficient = 8.0) + { + var extension = Path.GetExtension(path); + if (extension is null) + { + return IconSearchResult.NotFound(); + } + + var end = path.Length - extension.Length; + var prefix = path[..end]; + var pathSizePairs = new List<(string Path, int Size)>(); + + foreach (var size in TargetSizes) + { + if (highContrast) + { + pathSizePairs.Add(($"{prefix}.targetsize-{size}_{colorscheme}{extension}", size)); + pathSizePairs.Add(($"{prefix}.{colorscheme}_targetsize-{size}{extension}", size)); + } + else + { + pathSizePairs.Add(($"{prefix}.targetsize-{size}_altform-unplated{extension}", size)); + pathSizePairs.Add(($"{prefix}.targetsize-{size}{extension}", size)); + } + } + + var maxAllowedSize = (int)(appIconSize * maxSizeCoefficient); + var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored; + + string? bestLargerPath = null; + var bestLargerSize = int.MaxValue; + + string? bestSmallerPath = null; + var bestSmallerSize = 0; + + foreach (var (p, size) in pathSizePairs) + { + if (!File.Exists(p)) + { + continue; + } + + if (size >= appIconSize && size <= maxAllowedSize) + { + if (size < bestLargerSize) + { + bestLargerSize = size; + bestLargerPath = p; + } + } + else if (size < appIconSize) + { + if (size > bestSmallerSize) + { + bestSmallerSize = size; + bestSmallerPath = p; + } + } + } + + if (bestLargerPath is not null) + { + return IconSearchResult.FoundTargetSize(bestLargerPath, logoType, bestLargerSize); + } + + if (bestSmallerPath is not null) + { + return IconSearchResult.FoundTargetSize(bestSmallerPath, logoType, bestSmallerSize); + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult GetColoredIcon( + string path, + string colorscheme, + int iconSize, + UWP package) + { + // First priority: targetsize icons (we know the exact size) + var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize); + if (targetResult.MeetsMinimumSize(iconSize)) + { + return targetResult; + } + + var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize); + if (hcTargetResult.MeetsMinimumSize(iconSize)) + { + return hcTargetResult; + } + + // Second priority: scale icons (size unknown, but higher scale = likely better) + var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false); + if (scaleResult.IsFound) + { + return scaleResult; + } + + var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true); + if (hcScaleResult.IsFound) + { + return hcScaleResult; + } + + // Last resort: return undersized targetsize if we found one + if (targetResult.IsFound) + { + return targetResult; + } + + if (hcTargetResult.IsFound) + { + return hcTargetResult; + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult SetHighContrastIcon( + string path, + string colorscheme, + int iconSize, + UWP package) + { + // First priority: HC targetsize icons (we know the exact size) + var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize); + if (hcTargetResult.MeetsMinimumSize(iconSize)) + { + return hcTargetResult; + } + + var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize); + if (targetResult.MeetsMinimumSize(iconSize)) + { + return targetResult; + } + + // Second priority: scale icons + var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true); + if (hcScaleResult.IsFound) + { + return hcScaleResult; + } + + var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false); + if (scaleResult.IsFound) + { + return scaleResult; + } + + // Last resort: undersized targetsize + if (hcTargetResult.IsFound) + { + return hcTargetResult; + } + + if (targetResult.IsFound) + { + return targetResult; + } + + return IconSearchResult.NotFound(); + } + + /// + /// Loads an icon from a UWP package, attempting to find the best match for the requested size. + /// + /// The relative URI to the logo asset. + /// The current theme. + /// The requested icon size in pixels. + /// The UWP package. + /// + /// An IconSearchResult. Use to check if + /// the icon is confirmed to be large enough, or + /// to determine if the size is known. + /// + internal static IconSearchResult LogoPathFromUri( + string uri, + Theme theme, + int iconSize, + UWP package) + { + var path = Path.Combine(package.Location, uri); + var logo = Probe(theme, path, iconSize, package); + if (!logo.IsFound && !uri.Contains('\\', StringComparison.Ordinal)) + { + path = Path.Combine(package.Location, "Assets", uri); + logo = Probe(theme, path, iconSize, package); + } + + return logo; + } + + private static IconSearchResult Probe(Theme theme, string path, int iconSize, UWP package) + { + return theme switch + { + Theme.HighContrastBlack or Theme.HighContrastOne or Theme.HighContrastTwo + => SetHighContrastIcon(path, ContrastBlack, iconSize, package), + Theme.HighContrastWhite + => SetHighContrastIcon(path, ContrastWhite, iconSize, package), + Theme.Light + => GetColoredIcon(path, ContrastWhite, iconSize, package), + _ + => GetColoredIcon(path, ContrastBlack, iconSize, package), + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs new file mode 100644 index 0000000000..c1d04e286c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading.Tasks; +using ManagedCommon; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.Shell; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +internal static class IconExtractor +{ + public static async Task GetIconStreamAsync(string path, int size) + { + var bitmap = GetIcon(path, size); + if (bitmap == null) + { + return null; + } + + var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetSoftwareBitmap(bitmap); + await encoder.FlushAsync(); + + stream.Seek(0); + return stream; + } + + public static unsafe SoftwareBitmap? GetIcon(string path, int size) + { + IShellItemImageFactory* factory = null; + HBITMAP hBitmap = default; + + try + { + fixed (char* pPath = path) + { + var iid = IShellItemImageFactory.IID_Guid; + var hr = PInvoke.SHCreateItemFromParsingName( + pPath, + null, + &iid, + (void**)&factory); + + if (hr.Failed || factory == null) + { + return null; + } + } + + var requestedSize = new SIZE { cx = size, cy = size }; + var hr2 = factory->GetImage( + requestedSize, + SIIGBF.SIIGBF_ICONONLY | SIIGBF.SIIGBF_BIGGERSIZEOK | SIIGBF.SIIGBF_CROPTOSQUARE, + &hBitmap); + + if (hr2.Failed || hBitmap.IsNull) + { + return null; + } + + return CreateSoftwareBitmap(hBitmap, size); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load icon from path='{path}',size={size}", ex); + return null; + } + finally + { + if (!hBitmap.IsNull) + { + PInvoke.DeleteObject(hBitmap); + } + + if (factory != null) + { + factory->Release(); + } + } + } + + private static unsafe SoftwareBitmap CreateSoftwareBitmap(HBITMAP hBitmap, int size) + { + var pixels = new byte[size * size * 4]; + + var bmi = new BITMAPINFO + { + bmiHeader = new BITMAPINFOHEADER + { + biSize = (uint)sizeof(BITMAPINFOHEADER), + biWidth = size, + biHeight = -size, + biPlanes = 1, + biBitCount = 32, + biCompression = 0, + }, + }; + + var hdc = PInvoke.GetDC(default); + try + { + fixed (byte* pPixels = pixels) + { + _ = PInvoke.GetDIBits( + hdc, + hBitmap, + 0, + (uint)size, + pPixels, + &bmi, + DIB_USAGE.DIB_RGB_COLORS); + } + } + finally + { + _ = PInvoke.ReleaseDC(default, hdc); + } + + var bitmap = new SoftwareBitmap(BitmapPixelFormat.Bgra8, size, size, BitmapAlphaMode.Premultiplied); + bitmap.CopyFromBuffer(pixels.AsBuffer()); + return bitmap; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs new file mode 100644 index 0000000000..51c8a142cf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +/// +/// Result of an icon search operation. +/// +internal readonly record struct IconSearchResult( + string? LogoPath, + LogoType LogoType, + bool IsTargetSizeIcon, + int? KnownSize = null) +{ + /// + /// Gets a value indicating whether an icon was found. + /// + public bool IsFound => LogoPath is not null; + + /// + /// Returns true if we can confirm the icon meets the minimum size. + /// Only possible for targetsize icons where the size is encoded in the filename. + /// + public bool MeetsMinimumSize(int minimumSize) => + IsTargetSizeIcon && KnownSize >= minimumSize; + + /// + /// Returns true if we know the icon is undersized. + /// Returns false if not found, or if size is unknown (scale-based icons). + /// + public bool IsKnownUndersized(int minimumSize) => + IsTargetSizeIcon && KnownSize < minimumSize; + + public static IconSearchResult NotFound() => new(null, default, false); + + public static IconSearchResult FoundTargetSize(string path, LogoType logoType, int size) + => new(path, logoType, IsTargetSizeIcon: true, size); + + public static IconSearchResult FoundScaled(string path, LogoType logoType) + => new(path, logoType, IsTargetSizeIcon: false); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt index 017871d42f..86138d3fb2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt @@ -18,3 +18,9 @@ ShellLink IPersistFile CoTaskMemFree IUnknown +IShellItemImageFactory +DeleteObject +GetDIBits +GetDC +ReleaseDC +SIIGBF diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 4ec9598483..91b08d3b86 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -8,6 +8,7 @@ using System.IO.Abstractions; using System.Xml; using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Commands; +using Microsoft.CmdPal.Ext.Apps.Helpers; using Microsoft.CmdPal.Ext.Apps.Properties; using Microsoft.CmdPal.Ext.Apps.Utils; using Microsoft.CommandPalette.Extensions; @@ -23,8 +24,10 @@ namespace Microsoft.CmdPal.Ext.Apps.Programs; [Serializable] public class UWPApplication : IUWPApplication { + private const int ListIconSize = 20; + private const int JumboIconSize = 64; + private static readonly IFileSystem FileSystem = new FileSystem(); - private static readonly IPath Path = FileSystem.Path; private static readonly IFile File = FileSystem.File; public string AppListEntry { get; set; } = string.Empty; @@ -56,13 +59,15 @@ public class UWPApplication : IUWPApplication public LogoType LogoType { get; set; } + public string JumboLogoPath { get; set; } = string.Empty; + + public LogoType JumboLogoType { get; set; } + public UWP Package { get; set; } - private string logoUri; + private string _logoUri; - private const string ContrastWhite = "contrast-white"; - - private const string ContrastBlack = "contrast-black"; + private string _jumboLogoUri; // Function to set the subtitle based on the Type of application public static string Type() @@ -154,7 +159,8 @@ public class UWPApplication : IUWPApplication DisplayName = ResourceFromPri(package.FullName, DisplayName); Description = ResourceFromPri(package.FullName, Description); - logoUri = LogoUriFromManifest(manifestApp); + _logoUri = LogoUriFromManifest(manifestApp); + _jumboLogoUri = LogoUriFromManifest(manifestApp, jumbo: true); Enabled = true; CanRunElevated = IfApplicationCanRunElevated(); @@ -280,16 +286,24 @@ public class UWPApplication : IUWPApplication } } - private static readonly Dictionary _logoKeyFromVersion = new Dictionary + private static readonly Dictionary _smallLogoKeyFromVersion = new Dictionary { { PackageVersion.Windows10, "Square44x44Logo" }, { PackageVersion.Windows81, "Square30x30Logo" }, { PackageVersion.Windows8, "SmallLogo" }, }; - internal unsafe string LogoUriFromManifest(IAppxManifestApplication* app) + private static readonly Dictionary _largeLogoKeyFromVersion = new Dictionary { - if (_logoKeyFromVersion.TryGetValue(Package.Version, out var key)) + { PackageVersion.Windows10, "Square150x150Logo" }, + { PackageVersion.Windows81, "Square150x150Logo" }, + { PackageVersion.Windows8, "Logo" }, + }; + + internal unsafe string LogoUriFromManifest(IAppxManifestApplication* app, bool jumbo = false) + { + var logoMap = jumbo ? _largeLogoKeyFromVersion : _smallLogoKeyFromVersion; + if (logoMap.TryGetValue(Package.Version, out var key)) { var hr = app->GetStringValue(key, out var logoUriFromAppPtr); return ComFreeHelper.GetStringAndFree(hr, logoUriFromAppPtr); @@ -302,257 +316,55 @@ public class UWPApplication : IUWPApplication public void UpdateLogoPath(Theme theme) { - LogoPathFromUri(logoUri, theme); - } - - // scale factors on win10: https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets#asset-size-tables, - private static readonly Dictionary> _scaleFactors = new Dictionary> + // Update small logo + var logo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, ListIconSize, Package); + if (logo.IsFound) { - { PackageVersion.Windows10, new List { 100, 125, 150, 200, 400 } }, - { PackageVersion.Windows81, new List { 100, 120, 140, 160, 180 } }, - { PackageVersion.Windows8, new List { 100 } }, - }; - - private bool SetScaleIcons(string path, string colorscheme, bool highContrast = false) - { - var extension = Path.GetExtension(path); - if (extension is not null) - { - var end = path.Length - extension.Length; - var prefix = path.Substring(0, end); - var paths = new List { }; - - if (!highContrast) - { - paths.Add(path); - } - - if (_scaleFactors.TryGetValue(Package.Version, out var factors)) - { - foreach (var factor in factors) - { - if (highContrast) - { - paths.Add($"{prefix}.scale-{factor}_{colorscheme}{extension}"); - paths.Add($"{prefix}.{colorscheme}_scale-{factor}{extension}"); - } - else - { - paths.Add($"{prefix}.scale-{factor}{extension}"); - } - } - } - - // By working from the highest resolution to the lowest, we make - // sure that we use the highest quality possible icon for the app. - // - // FirstOrDefault would result in us using the 1x scaled icon - // always, which is usually too small for our needs. - for (var i = paths.Count - 1; i >= 0; i--) - { - if (File.Exists(paths[i])) - { - LogoPath = paths[i]; - if (highContrast) - { - LogoType = LogoType.HighContrast; - } - else - { - LogoType = LogoType.Colored; - } - - return true; - } - } - } - - return false; - } - - private bool SetTargetSizeIcon(string path, string colorscheme, bool highContrast = false) - { - var extension = Path.GetExtension(path); - if (extension is not null) - { - var end = path.Length - extension.Length; - var prefix = path.Substring(0, end); - var paths = new List { }; - const int appIconSize = 36; - var targetSizes = new List { 16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256 }; - var pathFactorPairs = new Dictionary(); - - foreach (var factor in targetSizes) - { - if (highContrast) - { - var suffixThemePath = $"{prefix}.targetsize-{factor}_{colorscheme}{extension}"; - var prefixThemePath = $"{prefix}.{colorscheme}_targetsize-{factor}{extension}"; - paths.Add(suffixThemePath); - paths.Add(prefixThemePath); - pathFactorPairs.Add(suffixThemePath, factor); - pathFactorPairs.Add(prefixThemePath, factor); - } - else - { - var simplePath = $"{prefix}.targetsize-{factor}{extension}"; - var altformUnPlatedPath = $"{prefix}.targetsize-{factor}_altform-unplated{extension}"; - paths.Add(simplePath); - paths.Add(altformUnPlatedPath); - pathFactorPairs.Add(simplePath, factor); - pathFactorPairs.Add(altformUnPlatedPath, factor); - } - } - - // Sort paths by distance to desired app icon size - var selectedIconPath = string.Empty; - var closestDistance = int.MaxValue; - - foreach (var p in paths) - { - if (File.Exists(p) && pathFactorPairs.TryGetValue(p, out var factor)) - { - var distance = Math.Abs(factor - appIconSize); - if (distance < closestDistance) - { - closestDistance = distance; - selectedIconPath = p; - } - } - } - - if (!string.IsNullOrEmpty(selectedIconPath)) - { - LogoPath = selectedIconPath; - if (highContrast) - { - LogoType = LogoType.HighContrast; - } - else - { - LogoType = LogoType.Colored; - } - - return true; - } - } - - return false; - } - - private bool SetColoredIcon(string path, string colorscheme) - { - var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme); - if (isSetColoredScaleIcon) - { - return true; - } - - var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme); - if (isSetColoredTargetIcon) - { - return true; - } - - var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true); - if (isSetHighContrastScaleIcon) - { - return true; - } - - var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true); - if (isSetHighContrastTargetIcon) - { - return true; - } - - return false; - } - - private bool SetHighContrastIcon(string path, string colorscheme) - { - var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true); - if (isSetHighContrastScaleIcon) - { - return true; - } - - var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true); - if (isSetHighContrastTargetIcon) - { - return true; - } - - var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme); - if (isSetColoredScaleIcon) - { - return true; - } - - var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme); - if (isSetColoredTargetIcon) - { - return true; - } - - return false; - } - - internal void LogoPathFromUri(string uri, Theme theme) - { - // all https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets - // windows 10 https://msdn.microsoft.com/library/windows/apps/dn934817.aspx - // windows 8.1 https://msdn.microsoft.com/library/windows/apps/hh965372.aspx#target_size - // windows 8 https://msdn.microsoft.com/library/windows/apps/br211475.aspx - string path; - bool isLogoUriSet; - - // Using Ordinal since this is used internally with uri - if (uri.Contains('\\', StringComparison.Ordinal)) - { - path = Path.Combine(Package.Location, uri); + LogoPath = logo.LogoPath!; + LogoType = logo.LogoType; } else - { - // for C:\Windows\MiracastView, etc. - path = Path.Combine(Package.Location, "Assets", uri); - } - - switch (theme) - { - case Theme.HighContrastBlack: - case Theme.HighContrastOne: - case Theme.HighContrastTwo: - isLogoUriSet = SetHighContrastIcon(path, ContrastBlack); - break; - case Theme.HighContrastWhite: - isLogoUriSet = SetHighContrastIcon(path, ContrastWhite); - break; - case Theme.Light: - isLogoUriSet = SetColoredIcon(path, ContrastWhite); - break; - default: - isLogoUriSet = SetColoredIcon(path, ContrastBlack); - break; - } - - if (!isLogoUriSet) { LogoPath = string.Empty; LogoType = LogoType.Error; } + + // Jumbo logo ... small logo can actually provide better result + var jumboLogo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, JumboIconSize, Package); + if (jumboLogo.IsFound) + { + JumboLogoPath = jumboLogo.LogoPath!; + JumboLogoType = jumboLogo.LogoType; + } + else + { + JumboLogoPath = string.Empty; + JumboLogoType = LogoType.Error; + } + + if (!jumboLogo.MeetsMinimumSize(JumboIconSize) || !jumboLogo.IsFound) + { + var jumboLogoAlt = AppxIconLoader.LogoPathFromUri(_jumboLogoUri, theme, JumboIconSize, Package); + if (jumboLogoAlt.IsFound) + { + JumboLogoPath = jumboLogoAlt.LogoPath!; + JumboLogoType = jumboLogoAlt.LogoType; + } + } } public AppItem ToAppItem() { var app = this; var iconPath = app.LogoType != LogoType.Error ? app.LogoPath : string.Empty; - var item = new AppItem() + var jumboIconPath = app.JumboLogoType != LogoType.Error ? app.JumboLogoPath : string.Empty; + var item = new AppItem { Name = app.Name, Subtitle = app.Description, Type = UWPApplication.Type(), IcoPath = iconPath, + JumboIconPath = jumboIconPath, DirPath = app.Location, UserModelId = app.UserModelId, IsPackaged = true, @@ -563,116 +375,6 @@ public class UWPApplication : IUWPApplication return item; } - /* - public ImageSource Logo() - { - if (LogoType == LogoType.Colored) - { - var logo = ImageFromPath(LogoPath); - var platedImage = PlatedImage(logo); - return platedImage; - } - else - { - return ImageFromPath(LogoPath); - } - } - - private const int _dpiScale100 = 96; - - private ImageSource PlatedImage(BitmapImage image) - { - if (!string.IsNullOrEmpty(BackgroundColor)) - { - string currentBackgroundColor; - if (BackgroundColor == "transparent") - { - // Using InvariantCulture since this is internal - currentBackgroundColor = SystemParameters.WindowGlassBrush.ToString(CultureInfo.InvariantCulture); - } - else - { - currentBackgroundColor = BackgroundColor; - } - - var padding = 8; - var width = image.Width + (2 * padding); - var height = image.Height + (2 * padding); - var x = 0; - var y = 0; - - var group = new DrawingGroup(); - var converted = ColorConverter.ConvertFromString(currentBackgroundColor); - if (converted is not null) - { - var color = (Color)converted; - var brush = new SolidColorBrush(color); - var pen = new Pen(brush, 1); - var backgroundArea = new Rect(0, 0, width, height); - var rectangleGeometry = new RectangleGeometry(backgroundArea, 8, 8); - var rectDrawing = new GeometryDrawing(brush, pen, rectangleGeometry); - group.Children.Add(rectDrawing); - - var imageArea = new Rect(x + padding, y + padding, image.Width, image.Height); - var imageDrawing = new ImageDrawing(image, imageArea); - group.Children.Add(imageDrawing); - - // http://stackoverflow.com/questions/6676072/get-system-drawing-bitmap-of-a-wpf-area-using-visualbrush - var visual = new DrawingVisual(); - var context = visual.RenderOpen(); - context.DrawDrawing(group); - context.Close(); - - var bitmap = new RenderTargetBitmap( - Convert.ToInt32(width), - Convert.ToInt32(height), - _dpiScale100, - _dpiScale100, - PixelFormats.Pbgra32); - - bitmap.Render(visual); - - return bitmap; - } - else - { - ProgramLogger.Exception($"Unable to convert background string {BackgroundColor} to color for {Package.Location}", new InvalidOperationException(), GetType(), Package.Location); - - return new BitmapImage(new Uri(Constant.ErrorIcon)); - } - } - else - { - // todo use windows theme as background - return image; - } - } - - private BitmapImage ImageFromPath(string path) - { - if (File.Exists(path)) - { - var memoryStream = new MemoryStream(); - using (var fileStream = File.OpenRead(path)) - { - fileStream.CopyTo(memoryStream); - memoryStream.Position = 0; - - var image = new BitmapImage(); - image.BeginInit(); - image.StreamSource = memoryStream; - image.EndInit(); - return image; - } - } - else - { - // ProgramLogger.Exception($"Unable to get logo for {UserModelId} from {path} and located in {Package.Location}", new FileNotFoundException(), GetType(), path); - return new BitmapImage(new Uri(ImageLoader.ErrorIconPath)); - } - } - */ - public override string ToString() { return $"{DisplayName}: {Description}";