CmdPal: Make Bookmarks Great and Fast Again (#41961)

## Summary of the Pull Request


This PR improves recognition and classification of bookmarks, allowing
CmdPal to recognize almost anything sensible a user can throw at
it—while being forgiving of common input issues (such as unquoted spaces
in paths, etc.).

Extended classification and exploration of edge cases also revealed
limitations in the current implementation, which reloaded all bookmarks
on every change. This caused visible UI lag and could lead to issues
like unintentionally adding the same bookmark multiple times.

### tl;dr

More details below

- Introduces `BookmarkManager` (async saves, thread-safe, immutable,
unique IDs, separate persistence).
- Adds `BookmarkResolver` (classification, Shell-like path/exe
resolution, better icons).
- `BookmarkListItem` now refreshes independently; Name is optional
(Shell fallback).
- Uses Shell API for user-friendly names and paths.  
- Adds `IIconLocator`, protocol icon support, Steam custom icon,
fallback icons and improved `FaviconLoader` (handles redirects). Every
bookmark should now have icon, so we have consistent UI without gaps.
- Refactors placeholders (`IPlaceholderParser`), adds tests, restricts
names to `[a-zA-Z0-9_-]`, excludes GUIDs.
- Reorganizes structure, syncs icons/key chords with AllApps/Indexer.  
- For web and protocol bookmarks URL-encodes placeholder values
- **Performance:** avoids full reloads, improves scalability, reduces UI
lag.
- **Breaking change:** stricter placeholder rules, bookmark command ids.


<img width="786" height="1392" alt="image"
src="https://github.com/user-attachments/assets/88d6617a-9f7c-47d1-bd60-80593fe414d3"
/>

<img width="786" height="1389" alt="image"
src="https://github.com/user-attachments/assets/8cdd3a09-73ae-439a-94ef-4e14d14c1ef3"
/>

<img width="896" height="461" alt="image"
src="https://github.com/user-attachments/assets/1f32e230-7d32-4710-b4c5-28e202c0e37b"
/>

<img width="862" height="391" alt="image"
src="https://github.com/user-attachments/assets/7649ce6a-3471-46f2-adc4-fb21bd4ecfed"
/>

<img width="844" height="356" alt="image"
src="https://github.com/user-attachments/assets/0c0b1941-fe5c-474e-94e9-de3817cb5470"
/>

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41705
- [x] Closes: #41892
- [x] Closes: #41872
- [x] Closes: #41545
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

### Changes

- **Bookmark Manager**  
  - Introduces a `BookmarkManager` class that:  
    - Holds bookmarks in memory and saves changes asynchronously.  
    - Is safe to operate from multiple threads.  
    - Uses immutable data for transport.  
    - Separates the **persistence model** from in-memory data.  
    - Assigns explicit unique IDs to bookmarks.  
- These IDs also serve as stable top-level command identifiers, enabling
aliases and shortcuts to be bound reliably.

- **Bookmark Resolver**  
- Determines the type of a bookmark (`CommandKind`: file, web link,
command, etc.).
  - Detects its target and parameters.  
- Returns a `Classification` object containing all information needed to
present the bookmark to the user (icon, primary command, context menu
actions, etc.).
- For unquoted local paths, attempts to find the *longest viable
matching path* to a file or executable, automatically handling spaces in
paths (e.g., `C:\Program Files`).
- The resolution of executables from the command line now more closely
matches **Windows Shell** behavior.
    - Users are more likely to get the correct result.  
    - Icons can be determined more reliably.  

- **Bookmark List Items**  
- Each top-level bookmark item (`BookmarkListItem`) is now responsible
for presenting itself.
  - Items refresh their state independently on load or after changes.  
  - The **Name** field is now optional.  
- If no explicit name is provided, a user-friendly fallback name is
computed automatically using the Shell API.
- Context actions are now more in line with **All Apps** and **Indexer**
built-in extensions, matching items, icons, and shortcuts (still a work
in progress).

- **Shell API Integration**  
- Uses the Shell API to provide friendly names and paths for shell or
file system items, keeping the UI aligned with the OS.

- **Protocol and Icon Support**  
  - Adds `IIconLocator` and protocol icon support.  
- Provides a custom icon for **Steam**, since Steam registers its
protocol to an executable not on the path (and the Steam protocol is
expected to be a common case).
  - Adds `FaviconLoader` for web links.  
- Can now follow redirects and retrieve the favicon even if the server
takes the request on a “sightseeing tour.”
- Provides **Fluent Segoe fallback icons** that match the bookmark
classification when no specific icon is available.

- **Refactors and Reorganization**  
  - Extracts `IPlaceholderParser` for testability and reusability.  
- Renames `Bookmarks` → `BookmarksData` to prevent naming collisions.
  - Reorganizes the structure (reducing root-level file clutter).  
  - Synchronizes icons and key chords with AllApps/Indexer.  
- Refactors placeholder parsing logic and **adds tests** to improve
reliability.

- **Misc**
- Correctly URL-encodes placeholder values in Web URL or protocol
bookmarks.

---

### Performance Improvements

- Eliminates full reloads of all bookmarks on every change.  
- Improves scalability when working with a large number of bookmarks.  
- Independent refresh of list items reduces UI lag and improves
responsiveness.
- Asynchronous persistence prevents blocking the UI thread on saves.  

---

### Breaking Changes

- **Placeholders**  
- Placeholder names are now restricted to letters (`a–z`, `A–Z`), digits
(`0–9`), uderscore (`_`), hyphen (`-`).
- GUIDs are explicitly excluded as valid placeholders to prevent
collisions with shell IDs.
- When presented to the user, placeholders are considered
case-insensitive.
- ** Bookmark Top-Level Command
- **Bookmark Top-Level Command**  
  - IDs for bookmark commands are now based on a unique identifier.  
  - This breaks existing bindings to shortcuts and aliases.  
- Newly created bindings will be stable regardless of changes to the
bookmark (name, address, or having placeholders).
  - 
<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Michael Jolley <mike@baldbeardedbuilder.com>
This commit is contained in:
Jiří Polášek
2025-10-01 23:45:01 +02:00
committed by GitHub
parent 0b9b91c060
commit 55f0bcc441
70 changed files with 6109 additions and 909 deletions

View File

@@ -13,6 +13,7 @@ public partial class OpenInConsoleCommand : InvokableCommand
internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756"); // "CommandPrompt"
private readonly string _path;
private bool _isDirectory;
public OpenInConsoleCommand(string fullPath)
{
@@ -21,11 +22,15 @@ public partial class OpenInConsoleCommand : InvokableCommand
this.Icon = OpenInConsoleIcon;
}
public static OpenInConsoleCommand FromDirectory(string directory) => new(directory) { _isDirectory = true };
public static OpenInConsoleCommand FromFile(string file) => new(file);
public override CommandResult Invoke()
{
using (var process = new Process())
{
process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_path);
process.StartInfo.WorkingDirectory = _isDirectory ? _path : Path.GetDirectoryName(_path);
process.StartInfo.FileName = "cmd.exe";
try

View File

@@ -6,11 +6,23 @@ using System.Runtime.InteropServices;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
internal sealed class NativeMethods
internal static partial class NativeMethods
{
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
internal static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags);
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
internal static extern IntPtr SHGetFileInfo(IntPtr pidl, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags);
[DllImport("shell32.dll")]
internal static extern int SHParseDisplayName([MarshalAs(UnmanagedType.LPWStr)] string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut);
[DllImport("ole32.dll")]
internal static extern void CoTaskMemFree(IntPtr pv);
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
internal static extern int SHLoadIndirectString(string pszSource, System.Text.StringBuilder pszOutBuf, int cchOutBuf, IntPtr ppvReserved);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct SHFILEINFO
{
@@ -33,4 +45,58 @@ internal sealed class NativeMethods
[DllImport("comctl32.dll", SetLastError = true)]
internal static extern int ImageList_GetIcon(IntPtr himl, int i, int flags);
[LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)]
internal static unsafe partial int AssocQueryStringW(
AssocF flags,
AssocStr str,
string pszAssoc,
string? pszExtra,
char* pszOut,
ref uint pcchOut);
// SHDefExtractIconW lets us ask for specific sizes (incl. 256)
// nIconSize: HIWORD = large size, LOWORD = small size
[LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)]
internal static partial int SHDefExtractIconW(
string pszIconFile,
int iIndex,
uint uFlags,
out nint phiconLarge,
out nint phiconSmall,
int nIconSize);
[Flags]
public enum AssocF : uint
{
None = 0,
IsProtocol = 0x00001000,
}
public enum AssocStr
{
Command = 1,
Executable,
FriendlyDocName,
FriendlyAppName,
NoOpen,
ShellNewValue,
DDECommand,
DDEIfExec,
DDEApplication,
DDETopic,
InfoTip,
QuickTip,
TileInfo,
ContentType,
DefaultIcon,
ShellExtension,
DropTarget,
DelegateExecute,
SupportedUriProtocols,
ProgId,
AppId,
AppPublisher,
AppIconReference, // sometimes present, but DefaultIcon is most common
}
}

View File

@@ -4,11 +4,68 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Win32;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public static class ShellHelpers
{
/// <summary>
/// These are the executable file extensions that Windows Shell recognizes. Unlike CMD/PowerShell,
/// Shell does not use PATHEXT, but has a magic fixed list.
/// </summary>
public static string[] ExecutableExtensions { get; } = [".PIF", ".COM", ".EXE", ".BAT", ".CMD"];
/// <summary>
/// Determines whether the specified file name represents an executable file
/// by examining its extension against the known list of Windows Shell
/// executable extensions (a fixed list that does not honor PATHEXT).
/// </summary>
/// <param name="fileName">The file name (with or without path) whose extension will be evaluated.</param>
/// <returns>
/// True if the file name has an extension that matches one of the recognized executable
/// extensions; otherwise, false. Returns false for null, empty, or whitespace input.
/// </returns>
public static bool IsExecutableFile(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
var fileExtension = Path.GetExtension(fileName);
return IsExecutableExtension(fileExtension);
}
/// <summary>
/// Determines whether the provided file extension (including the leading dot)
/// is one of the Windows Shell recognized executable extensions.
/// </summary>
/// <param name="fileExtension">The file extension to test. Should include the leading dot (e.g. ".exe").</param>
/// <returns>
/// True if the extension matches (case-insensitive) one of the known executable
/// extensions; false if it does not match or if the input is null/whitespace.
/// </returns>
public static bool IsExecutableExtension(string fileExtension)
{
if (string.IsNullOrWhiteSpace(fileExtension))
{
// Shell won't execute app with a filename without an extension
return false;
}
foreach (var extension in ExecutableExtensions)
{
if (string.Equals(fileExtension, extension, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public static bool OpenCommandInShell(string? path, string? pattern, string? arguments, string? workingDir = null, ShellRunAsType runAs = ShellRunAsType.None, bool runWithHiddenWindow = false)
{
if (string.IsNullOrEmpty(pattern))
@@ -127,7 +184,7 @@ public static class ShellHelpers
var values = Environment.GetEnvironmentVariable("PATH");
if (values is not null)
{
foreach (var path in values.Split(';'))
foreach (var path in values.Split(Path.PathSeparator))
{
var path1 = Path.Combine(path, filename);
if (File.Exists(path1))
@@ -147,13 +204,78 @@ public static class ShellHelpers
token?.ThrowIfCancellationRequested();
}
}
return false;
}
else
{
return false;
}
return false;
}
}
private static bool TryResolveFromAppPaths(string name, [NotNullWhen(true)] out string? fullPath)
{
try
{
fullPath = TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry64) ??
TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry32) ??
TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry64) ??
TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry32) ?? string.Empty;
return !string.IsNullOrEmpty(fullPath);
string? TryHiveView(RegistryHive hive, RegistryView view)
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var k1 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}.exe");
var val = (k1?.GetValue(null) as string)?.Trim('"');
if (!string.IsNullOrEmpty(val))
{
return val;
}
// Some vendors create keys without .exe in the subkey name; check that too.
using var k2 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}");
return (k2?.GetValue(null) as string)?.Trim('"');
}
}
catch (Exception)
{
fullPath = null;
return false;
}
}
/// <summary>
/// Mimics Windows Shell behavior to resolve an executable name to a full path.
/// </summary>
/// <param name="name"></param>
/// <param name="fullPath"></param>
/// <returns></returns>
public static bool TryResolveExecutableAsShell(string name, out string fullPath)
{
// First check if we can find the file in the registry
if (TryResolveFromAppPaths(name, out var path))
{
fullPath = path;
return true;
}
// If the name does not have an extension, try adding common executable extensions
// this order mimics Windows Shell behavior
// Note: HasExtension check follows Shell behavior, but differs from the
// Start Menu search results, which will offer file name with extensions + ".exe"
var nameHasExtension = Path.HasExtension(name);
if (!nameHasExtension)
{
foreach (var ext in ExecutableExtensions)
{
var nameWithExt = name + ext;
if (FileExistInPath(nameWithExt, out fullPath))
{
return true;
}
}
}
fullPath = string.Empty;
return false;
}
}

View File

@@ -2,16 +2,18 @@
// 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.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Windows.Storage;
using Windows.Storage.FileProperties;
using Windows.Storage.Streams;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public class ThumbnailHelper
public static class ThumbnailHelper
{
private static readonly string[] ImageExtensions =
[
@@ -24,26 +26,46 @@ public class ThumbnailHelper
".ico",
];
public static Task<IRandomAccessStream?> GetThumbnail(string path, bool jumbo = false)
public static async Task<IRandomAccessStream?> GetThumbnail(string path, bool jumbo = false)
{
var extension = Path.GetExtension(path).ToLower(CultureInfo.InvariantCulture);
var isImage = ImageExtensions.Contains(extension);
if (isImage)
{
try
{
var result = await GetImageThumbnailAsync(path, jumbo);
if (result is not null)
{
return result;
}
}
catch (Exception)
{
// ignore and fall back to icon
}
}
try
{
return ImageExtensions.Contains(extension) ? GetImageThumbnailAsync(path) : GetFileIconStream(path, jumbo);
return await GetFileIconStream(path, jumbo);
}
catch (Exception)
{
// ignore and return null
}
return Task.FromResult<IRandomAccessStream?>(null);
return null;
}
// these are windows constants and mangling them is goofy
#pragma warning disable SA1310 // Field names should not contain underscore
#pragma warning disable SA1306 // Field names should begin with lower-case letter
private const uint SHGFI_ICON = 0x000000100;
private const uint SHGFI_LARGEICON = 0x000000000;
private const uint SHGFI_SHELLICONSIZE = 0x000000004;
private const int SHGFI_SYSICONINDEX = 0x000004000;
private const uint SHGFI_SYSICONINDEX = 0x000004000;
private const uint SHGFI_PIDL = 0x000000008;
private const int SHIL_JUMBO = 4;
private const int ILD_TRANSPARENT = 1;
#pragma warning restore SA1306 // Field names should begin with lower-case letter
@@ -69,6 +91,59 @@ public class ThumbnailHelper
}
private static async Task<IRandomAccessStream?> GetFileIconStream(string filePath, bool jumbo)
{
return await TryExtractUsingPIDL(filePath, jumbo)
?? await GetFileIconStreamUsingFilePath(filePath, jumbo);
}
private static async Task<IRandomAccessStream?> TryExtractUsingPIDL(string shellPath, bool jumbo)
{
IntPtr pidl = 0;
try
{
var hr = NativeMethods.SHParseDisplayName(shellPath, IntPtr.Zero, out pidl, 0, out _);
if (hr != 0 || pidl == IntPtr.Zero)
{
return null;
}
nint hIcon = 0;
if (jumbo)
{
hIcon = GetLargestIcon(pidl);
}
if (hIcon == 0)
{
var shinfo = default(NativeMethods.SHFILEINFO);
var fileInfoResult = NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_ICON | SHGFI_SHELLICONSIZE | SHGFI_LARGEICON | SHGFI_PIDL);
if (fileInfoResult != IntPtr.Zero && shinfo.hIcon != IntPtr.Zero)
{
hIcon = shinfo.hIcon;
}
}
if (hIcon == 0)
{
return null;
}
return await FromHIconToStream(hIcon);
}
catch (Exception)
{
return null;
}
finally
{
if (pidl != IntPtr.Zero)
{
NativeMethods.CoTaskMemFree(pidl);
}
}
}
private static async Task<IRandomAccessStream?> GetFileIconStreamUsingFilePath(string filePath, bool jumbo)
{
nint hIcon = 0;
@@ -99,41 +174,262 @@ public class ThumbnailHelper
return null;
}
var stream = new InMemoryRandomAccessStream();
using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon
using var outputStream = stream.GetOutputStreamAt(0);
using (var dataWriter = new DataWriter(outputStream))
{
dataWriter.WriteBytes(memoryStream.ToArray());
await dataWriter.StoreAsync();
await dataWriter.FlushAsync();
}
return stream;
return await FromHIconToStream(hIcon);
}
private static async Task<IRandomAccessStream?> GetImageThumbnailAsync(string filePath)
private static async Task<IRandomAccessStream?> GetImageThumbnailAsync(string filePath, bool jumbo)
{
var file = await StorageFile.GetFileFromPathAsync(filePath);
var thumbnail = await file.GetThumbnailAsync(ThumbnailMode.PicturesView);
var thumbnail = await file.GetThumbnailAsync(
jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView,
jumbo ? 64u : 20u);
return thumbnail;
}
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 Naming/Private")]
private static readonly Guid IID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950");
private static nint GetLargestIcon(string path)
{
var shinfo = default(NativeMethods.SHFILEINFO);
NativeMethods.SHGetFileInfo(path, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX);
var hIcon = IntPtr.Zero;
var iID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950");
IntPtr imageListPtr;
var iID_IImageList = IID_IImageList;
if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out imageListPtr) == 0 && imageListPtr != IntPtr.Zero)
if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero)
{
hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT);
}
return hIcon;
}
private static nint GetLargestIcon(IntPtr pidl)
{
var shinfo = default(NativeMethods.SHFILEINFO);
NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX | SHGFI_PIDL);
var hIcon = IntPtr.Zero;
var iID_IImageList = IID_IImageList;
if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero)
{
hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT);
}
return hIcon;
}
/// <summary>
/// Get an icon stream for a registered URI protocol (e.g. "mailto:", "http:", "steam:").
/// </summary>
public static async Task<IRandomAccessStream?> GetProtocolIconStream(string protocol, bool jumbo)
{
// 1) Ask the shell for the protocol's default icon "path,index"
var iconRef = QueryProtocolIconReference(protocol);
if (string.IsNullOrWhiteSpace(iconRef))
{
return null;
}
// Indirect reference:
if (iconRef.StartsWith('@'))
{
if (TryLoadIndirectString(iconRef, out var expanded) && !string.IsNullOrWhiteSpace(expanded))
{
iconRef = expanded;
}
}
// 2) Handle .png files returned by store apps
if (iconRef.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
{
try
{
var file = await StorageFile.GetFileFromPathAsync(iconRef);
var thumbnail = await file.GetThumbnailAsync(
jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView,
jumbo ? 64u : 20u);
return thumbnail;
}
catch (Exception)
{
return null;
}
}
// 3) Parse "path,index" (index can be negative)
if (!TryParseIconReference(iconRef, out var path, out var index))
{
return null;
}
// if it's and .exe and without a path, let's find on path:
if (Path.GetExtension(path).Equals(".exe", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(path))
{
var paths = Environment.GetEnvironmentVariable("PATH")?.Split(';') ?? [];
foreach (var p in paths)
{
var candidate = Path.Combine(p, path);
if (File.Exists(candidate))
{
path = candidate;
break;
}
}
}
// 3) Extract an HICON, preferably ~256px when jumbo==true
var hIcon = ExtractIconHandle(path, index, jumbo);
if (hIcon == 0)
{
return null;
}
return await FromHIconToStream(hIcon);
}
private static bool TryLoadIndirectString(string input, out string? output)
{
var outBuffer = new StringBuilder(1024);
var hr = NativeMethods.SHLoadIndirectString(input, outBuffer, outBuffer.Capacity, IntPtr.Zero);
if (hr == 0)
{
output = outBuffer.ToString();
return !string.IsNullOrWhiteSpace(output);
}
output = null;
return false;
}
private static async Task<IRandomAccessStream?> FromHIconToStream(IntPtr hIcon)
{
var stream = new InMemoryRandomAccessStream();
using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon
using var outputStream = stream.GetOutputStreamAt(0);
using var dataWriter = new DataWriter(outputStream);
dataWriter.WriteBytes(memoryStream.ToArray());
await dataWriter.StoreAsync();
await dataWriter.FlushAsync();
return stream;
}
private static string? QueryProtocolIconReference(string protocol)
{
// First try DefaultIcon (most widely populated for protocols)
// If you want to try AppIconReference as a fallback, you can repeat with AssocStr.AppIconReference.
var iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.DefaultIcon, protocol);
if (!string.IsNullOrWhiteSpace(iconReference))
{
return iconReference;
}
// Optional fallback some registrations use AppIconReference:
iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.AppIconReference, protocol);
return iconReference;
static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol)
{
uint cch = 0;
// First call: get required length (incl. null)
_ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch);
if (cch == 0)
{
return null;
}
// Small buffers on stack; large on heap
var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch];
fixed (char* p = span)
{
var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch);
if (hr != 0 || cch == 0)
{
return null;
}
// cch includes the null terminator; slice it off
var len = (int)cch - 1;
if (len < 0)
{
len = 0;
}
return new string(span.Slice(0, len));
}
}
}
private static bool TryParseIconReference(string iconRef, out string path, out int index)
{
// Typical shapes:
// "C:\Program Files\Outlook\OUTLOOK.EXE,-1"
// "shell32.dll,21"
// "\"C:\Some Path\app.dll\",-325"
// If there's no comma, assume ",0"
index = 0;
path = iconRef.Trim();
// Split only on the last comma so paths with commas still work
var lastComma = path.LastIndexOf(',');
if (lastComma >= 0)
{
var idxPart = path[(lastComma + 1)..].Trim();
path = path[..lastComma].Trim();
_ = int.TryParse(idxPart, out index);
}
// Trim quotes around path
path = path.Trim('"');
if (path.Length > 1 && path[0] == '"' && path[^1] == '"')
{
path = path.Substring(1, path.Length - 2);
}
// Basic sanity
return !string.IsNullOrWhiteSpace(path);
}
private static nint ExtractIconHandle(string path, int index, bool jumbo)
{
// Request sizes: LOWORD=small, HIWORD=large.
// Ask for 256 when jumbo, else fall back to 32/16.
var small = jumbo ? 256 : 16;
var large = jumbo ? 256 : 32;
var sizeParam = (large << 16) | (small & 0xFFFF);
var hr = NativeMethods.SHDefExtractIconW(path, index, 0, out var hLarge, out var hSmall, sizeParam);
if (hr == 0 && hLarge != 0)
{
return hLarge;
}
if (hr == 0 && hSmall != 0)
{
return hSmall;
}
// Final fallback: try 32/16 explicitly in case the resource cant upscale
sizeParam = (32 << 16) | 16;
hr = NativeMethods.SHDefExtractIconW(path, index, 0, out hLarge, out hSmall, sizeParam);
if (hr == 0 && hLarge != 0)
{
return hLarge;
}
if (hr == 0 && hSmall != 0)
{
return hSmall;
}
return 0;
}
}