mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 17:56:44 +02:00
## 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>
282 lines
10 KiB
C#
282 lines
10 KiB
C#
// 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.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))
|
|
{
|
|
// Log.Warn($"Trying to run OpenCommandInShell with an empty pattern. The default browser definition might have issues. Path: '${path ?? string.Empty}' ; Arguments: '${arguments ?? string.Empty}' ; Working Directory: '${workingDir ?? string.Empty}'", typeof(ShellHelpers));
|
|
}
|
|
else if (pattern.Contains("%1", StringComparison.Ordinal))
|
|
{
|
|
arguments = pattern.Replace("%1", arguments);
|
|
}
|
|
|
|
return OpenInShell(path, arguments, workingDir, runAs, runWithHiddenWindow);
|
|
}
|
|
|
|
public static bool OpenInShell(string? path, string? arguments = null, string? workingDir = null, ShellRunAsType runAs = ShellRunAsType.None, bool runWithHiddenWindow = false)
|
|
{
|
|
using var process = new Process();
|
|
process.StartInfo.FileName = path;
|
|
process.StartInfo.WorkingDirectory = string.IsNullOrWhiteSpace(workingDir) ? string.Empty : workingDir;
|
|
process.StartInfo.Arguments = string.IsNullOrWhiteSpace(arguments) ? string.Empty : arguments;
|
|
process.StartInfo.WindowStyle = runWithHiddenWindow ? ProcessWindowStyle.Hidden : ProcessWindowStyle.Normal;
|
|
process.StartInfo.UseShellExecute = true;
|
|
|
|
if (runAs == ShellRunAsType.Administrator)
|
|
{
|
|
process.StartInfo.Verb = "RunAs";
|
|
}
|
|
else if (runAs == ShellRunAsType.OtherUser)
|
|
{
|
|
process.StartInfo.Verb = "RunAsUser";
|
|
}
|
|
|
|
try
|
|
{
|
|
process.Start();
|
|
return true;
|
|
}
|
|
catch (Win32Exception)
|
|
{
|
|
// Log.Exception($"Unable to open {path}: {ex.Message}", ex, MethodBase.GetCurrentMethod().DeclaringType);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public enum ShellRunAsType
|
|
{
|
|
None,
|
|
Administrator,
|
|
OtherUser,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the input string to extract the executable and its arguments.
|
|
/// </summary>
|
|
public static void ParseExecutableAndArgs(string input, out string executable, out string arguments)
|
|
{
|
|
input = input.Trim();
|
|
executable = string.Empty;
|
|
arguments = string.Empty;
|
|
|
|
if (string.IsNullOrEmpty(input))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (input.StartsWith("\"", System.StringComparison.InvariantCultureIgnoreCase))
|
|
{
|
|
// Find the closing quote
|
|
var closingQuoteIndex = input.IndexOf('\"', 1);
|
|
if (closingQuoteIndex > 0)
|
|
{
|
|
executable = input.Substring(1, closingQuoteIndex - 1);
|
|
if (closingQuoteIndex + 1 < input.Length)
|
|
{
|
|
arguments = input.Substring(closingQuoteIndex + 1).TrimStart();
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Executable ends at first space
|
|
var firstSpaceIndex = input.IndexOf(' ');
|
|
if (firstSpaceIndex > 0)
|
|
{
|
|
executable = input.Substring(0, firstSpaceIndex);
|
|
arguments = input[(firstSpaceIndex + 1)..].TrimStart();
|
|
}
|
|
else
|
|
{
|
|
executable = input;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a file exists somewhere in the PATH.
|
|
/// If it exists, returns the full path to the file in the out parameter.
|
|
/// If it does not exist, returns false and the out parameter is set to an empty string.
|
|
/// <param name="filename">The name of the file to check.</param>
|
|
/// <param name="fullPath">The full path to the file if it exists; otherwise an empty string.</param>
|
|
/// <param name="token">An optional cancellation token to cancel the operation.</param>
|
|
/// <returns>True if the file exists in the PATH; otherwise false.</returns>
|
|
/// </summary>
|
|
public static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
|
|
{
|
|
fullPath = string.Empty;
|
|
|
|
if (File.Exists(filename))
|
|
{
|
|
token?.ThrowIfCancellationRequested();
|
|
fullPath = Path.GetFullPath(filename);
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
var values = Environment.GetEnvironmentVariable("PATH");
|
|
if (values is not null)
|
|
{
|
|
foreach (var path in values.Split(Path.PathSeparator))
|
|
{
|
|
var path1 = Path.Combine(path, filename);
|
|
if (File.Exists(path1))
|
|
{
|
|
fullPath = Path.GetFullPath(path1);
|
|
return true;
|
|
}
|
|
|
|
token?.ThrowIfCancellationRequested();
|
|
|
|
var path2 = Path.Combine(path, filename + ".exe");
|
|
if (File.Exists(path2))
|
|
{
|
|
fullPath = Path.GetFullPath(path2);
|
|
return true;
|
|
}
|
|
|
|
token?.ThrowIfCancellationRequested();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|