mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-06 11:16:51 +02:00
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:
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
public sealed record Classification(
|
||||
CommandKind Kind,
|
||||
string Input,
|
||||
string Target,
|
||||
string Arguments,
|
||||
LaunchMethod Launch,
|
||||
string? WorkingDirectory,
|
||||
bool IsPlaceholder,
|
||||
string? FileSystemTarget = null,
|
||||
string? DisplayName = null)
|
||||
{
|
||||
public static Classification Unknown(string rawInput) =>
|
||||
new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandIds
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether
|
||||
/// the bookmark type of if it is a placeholder bookmark or not.
|
||||
/// </summary>
|
||||
/// <param name="id">Bookmark ID</param>
|
||||
public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a command or bookmark target type.
|
||||
/// </summary>
|
||||
public enum CommandKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or unsupported target.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTTP/HTTPS URL.
|
||||
/// </summary>
|
||||
WebUrl,
|
||||
|
||||
/// <summary>
|
||||
/// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:).
|
||||
/// </summary>
|
||||
Protocol,
|
||||
|
||||
/// <summary>
|
||||
/// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app).
|
||||
/// </summary>
|
||||
Aumid,
|
||||
|
||||
/// <summary>
|
||||
/// Existing folder path.
|
||||
/// </summary>
|
||||
Directory,
|
||||
|
||||
/// <summary>
|
||||
/// Existing executable file (e.g., .exe, .bat, .cmd).
|
||||
/// </summary>
|
||||
FileExecutable,
|
||||
|
||||
/// <summary>
|
||||
/// Existing document file.
|
||||
/// </summary>
|
||||
FileDocument,
|
||||
|
||||
/// <summary>
|
||||
/// Windows shortcut file (*.lnk).
|
||||
/// </summary>
|
||||
Shortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Internet shortcut file (*.url).
|
||||
/// </summary>
|
||||
InternetShortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git").
|
||||
/// </summary>
|
||||
PathCommand,
|
||||
|
||||
/// <summary>
|
||||
/// Shell item not matching other types (e.g., Control Panel item, purely virtual directory).
|
||||
/// </summary>
|
||||
VirtualShellItem,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches the classified item.
|
||||
/// </summary>
|
||||
/// <param name="classification">Classification produced by CommandClassifier.</param>
|
||||
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
|
||||
public static bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
switch (classification.Launch)
|
||||
{
|
||||
case LaunchMethod.ExplorerOpen:
|
||||
// Folders and shell: URIs are best handled by explorer.exe
|
||||
// You can notice the difference with Recycle Bin for example:
|
||||
// - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
// - "::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
return ShellHelpers.OpenInShell("explorer.exe", classification.Target);
|
||||
|
||||
case LaunchMethod.ActivateAppId:
|
||||
return ActivateAppId(classification.Target, classification.Arguments);
|
||||
|
||||
case LaunchMethod.ShellExecute:
|
||||
default:
|
||||
return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments)
|
||||
{
|
||||
const string shellAppsFolder = "shell:AppsFolder\\";
|
||||
try
|
||||
{
|
||||
if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..];
|
||||
}
|
||||
|
||||
ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class ApplicationActivationManager
|
||||
{
|
||||
public static void ActivateApplication(string aumid, string? args, int options, out uint pid)
|
||||
{
|
||||
var mgr = (IApplicationActivationManager)new _ApplicationActivationManager();
|
||||
var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid);
|
||||
if (hr < 0)
|
||||
{
|
||||
throw new Win32Exception(hr);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")]
|
||||
private class _ApplicationActivationManager;
|
||||
|
||||
[ComImport]
|
||||
[Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IApplicationActivationManager
|
||||
{
|
||||
int ActivateApplication(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string arguments,
|
||||
int options,
|
||||
out uint processId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// 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.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for parsing command lines and expanding paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser.
|
||||
/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also
|
||||
/// bend the rules to be more forgiving.
|
||||
/// </remarks>
|
||||
internal static partial class CommandLineHelper
|
||||
{
|
||||
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
|
||||
|
||||
public static string[] SplitCommandLine(string commandLine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLine);
|
||||
|
||||
var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc);
|
||||
if (argv == IntPtr.Zero)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = new string[argc];
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
result[i] = Marshal.PtrToStringUni(p)!;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.LocalFree(argv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules
|
||||
/// of CommandLineToArgvW.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a mental support for SplitLongestHeadBeforeQuotedArg.
|
||||
///
|
||||
/// Rules:
|
||||
/// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules).
|
||||
/// - Otherwise, Head uses the CreateProcess "program name" rule:
|
||||
/// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it).
|
||||
/// - Else, Head is the run up to the first whitespace.
|
||||
/// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains).
|
||||
/// No normalization is performed; returned slices preserve the original text (no un/escaping).
|
||||
/// </remarks>
|
||||
public static (string Head, string Tail) SplitHeadAndArgs(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
var i = 0;
|
||||
|
||||
// Leading whitespace -> empty argv[0]
|
||||
if (char.IsWhiteSpace(s[0]))
|
||||
{
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tailAfterWs = i < n ? input[i..] : string.Empty;
|
||||
return (string.Empty, tailAfterWs);
|
||||
}
|
||||
|
||||
string head;
|
||||
if (s[i] == '"')
|
||||
{
|
||||
// Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here)
|
||||
i++;
|
||||
var start = i;
|
||||
while (i < n && s[i] != '"')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
if (i < n && s[i] == '"')
|
||||
{
|
||||
i++; // consume closing quote
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unquoted program name: read to next whitespace
|
||||
var start = i;
|
||||
while (i < n && !char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
}
|
||||
|
||||
// Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty)
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tail = i < n ? input[i..] : string.Empty;
|
||||
|
||||
return (head, tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the longest possible head (may include spaces) and the tail that starts at the
|
||||
/// first *quoted argument*.
|
||||
///
|
||||
/// Definition of "quoted argument start":
|
||||
/// - A token boundary (start-of-line or preceded by whitespace),
|
||||
/// - followed by zero or more backslashes,
|
||||
/// - followed by a double-quote ("),
|
||||
/// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting).
|
||||
///
|
||||
/// Notes:
|
||||
/// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head.
|
||||
/// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote.
|
||||
/// - Leading whitespace before the first token is ignored (Head starts from first non-ws).
|
||||
/// Examples:
|
||||
/// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q"
|
||||
/// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args"
|
||||
/// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: ""
|
||||
/// </summary>
|
||||
public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
|
||||
// Start at first non-whitespace (we don't treat leading ws as part of Head here)
|
||||
var start = 0;
|
||||
while (start < n && char.IsWhiteSpace(s[start]))
|
||||
{
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start >= n)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
// Scan for a quote that OPENS a quoted argument at a token boundary.
|
||||
for (var i = start; i < n; i++)
|
||||
{
|
||||
if (s[i] != '"')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count immediate backslashes before this quote
|
||||
int j = i - 1, backslashes = 0;
|
||||
while (j >= start && s[j] == '\\')
|
||||
{
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
|
||||
// The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace.
|
||||
var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]);
|
||||
|
||||
// Even number of backslashes -> this quote toggles quoting (opens if at boundary).
|
||||
if (atTokenBoundary && (backslashes % 2 == 0))
|
||||
{
|
||||
// Trim trailing spaces off Head so Tail starts exactly at the opening quote
|
||||
var headEnd = i;
|
||||
while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1]))
|
||||
{
|
||||
headEnd--;
|
||||
}
|
||||
|
||||
var head = input[start..headEnd];
|
||||
var tail = input[headEnd..]; // starts at the opening quote
|
||||
return (head, tail.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
// No quoted-arg start found: entire remainder (trimmed right) is the Head
|
||||
var wholeHead = input[start..].TrimEnd();
|
||||
return (wholeHead, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers.
|
||||
/// </summary>
|
||||
internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
full = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||
|
||||
var firstSegment = GetFirstPathSegment(expanded);
|
||||
if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded))
|
||||
{
|
||||
expanded = shellExpanded;
|
||||
}
|
||||
else if (firstSegment is "~" or "." or "..")
|
||||
{
|
||||
expanded = ExpandUserRelative(firstSegment, expanded);
|
||||
}
|
||||
|
||||
if (Path.Exists(expanded))
|
||||
{
|
||||
full = Path.GetFullPath(expanded);
|
||||
return true;
|
||||
}
|
||||
|
||||
full = expanded; // return the attempted expansion even if it doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExpandShellMoniker(string input, out string expanded)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty;
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath))
|
||||
{
|
||||
expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
expanded = input;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ExpandUserRelative(string firstSegment, string input)
|
||||
{
|
||||
// Treat relative paths as relative to the user home directory.
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
if (firstSegment == "~")
|
||||
{
|
||||
// Remove "~" (+ optional following separator) before combining.
|
||||
var skip = 1;
|
||||
if (input.Length > 1 && IsSeparator(input[1]))
|
||||
{
|
||||
skip++;
|
||||
}
|
||||
|
||||
input = input[skip..];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(homeDirectory, input));
|
||||
}
|
||||
|
||||
private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
|
||||
|
||||
private static string GetFirstPathSegment(string input)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
return separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
}
|
||||
|
||||
internal static bool HasShellPrefix(string input)
|
||||
{
|
||||
return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
public enum LaunchMethod
|
||||
{
|
||||
ShellExecute, // UseShellExecute = true (Explorer/associations/protocols)
|
||||
ExplorerOpen, // explorer.exe <folder/shell:uri>
|
||||
ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHParseDisplayName(
|
||||
string pszName,
|
||||
nint pbc,
|
||||
out nint ppidl,
|
||||
uint sfgaoIn,
|
||||
nint psfgaoOut);
|
||||
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHGetNameFromIDList(
|
||||
nint pidl,
|
||||
SIGDN sigdnName,
|
||||
out nint ppszName);
|
||||
|
||||
[LibraryImport("ole32.dll")]
|
||||
internal static partial void CoTaskMemFree(nint pv);
|
||||
|
||||
[LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
internal static partial IntPtr LocalFree(IntPtr hMem);
|
||||
|
||||
internal enum SIGDN : uint
|
||||
{
|
||||
NORMALDISPLAY = 0x00000000,
|
||||
DESKTOPABSOLUTEPARSING = 0x80028000,
|
||||
DESKTOPABSOLUTEEDITING = 0x8004C000,
|
||||
FILESYSPATH = 0x80058000,
|
||||
URL = 0x80068000,
|
||||
PARENTRELATIVE = 0x80080001,
|
||||
PARENTRELATIVEFORADDRESSBAR = 0x8007C001,
|
||||
PARENTRELATIVEPARSING = 0x80018001,
|
||||
PARENTRELATIVEEDITING = 0x80031001,
|
||||
PARENTRELATIVEFORUI = 0x80094001,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for getting user-friendly shell names and paths.
|
||||
/// </summary>
|
||||
internal static class ShellNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like:
|
||||
/// - "shell:Downloads"
|
||||
/// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// </summary>
|
||||
public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName)
|
||||
{
|
||||
displayName = null;
|
||||
|
||||
// Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}"
|
||||
if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}')
|
||||
{
|
||||
shellPath = "::" + shellPath;
|
||||
}
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ask for the human-friendly localized name
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
displayName = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(displayName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optionally, also try to obtain a filesystem path (if the item represents one).
|
||||
/// Returns false for purely virtual items like "This PC".
|
||||
/// </summary>
|
||||
public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath)
|
||||
{
|
||||
fileSystemPath = null;
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
fileSystemPath = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(fileSystemPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class UriHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to split a URI string into scheme and remainder.
|
||||
/// Scheme must be valid per RFC 3986 and followed by ':'.
|
||||
/// </summary>
|
||||
public static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder)
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#page-17
|
||||
scheme = string.Empty;
|
||||
remainder = string.Empty;
|
||||
|
||||
if (input.Length < 2)
|
||||
{
|
||||
return false; // must have at least "a:"
|
||||
}
|
||||
|
||||
// Must contain ':' delimiter
|
||||
var colonIndex = input.IndexOf(':');
|
||||
if (colonIndex <= 0)
|
||||
{
|
||||
return false; // no colon or colon at start
|
||||
}
|
||||
|
||||
// First char must be a letter
|
||||
var first = input[0];
|
||||
if (!char.IsLetter(first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate scheme part
|
||||
for (var i = 1; i < colonIndex; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scheme and remainder
|
||||
scheme = input[..colonIndex].ToString();
|
||||
remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user