CmdPal: Find app for WinGet package (#43943)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This PR introduces a bit of dark magic to resolve the correct installed
app for a given WinGet package:

- Packaged apps: matched using their package family name.
- Everything else: matched using the product code (GUID) and heuristic
registry lookup.
- The registry rarely stores the executable path directly, so the logic
compares install locations with known apps.
  - It attempts to pick the best candidate while avoiding uninstallers.
  - It’s not science — let’s call it `#666666` magic.
- MSI API support was removed because it's too slow for this scenario.
- If no reliable match is found, the command is skipped for now. The
future plan is to redirect the user to the list of installed apps and
search by display name, but that needs some supporting infrastructure
first.
- The command order for WinGet list entries was updated: **Install /
Uninstall** is now the primary action, ensuring a stable UI since this
command is always available.


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

- [x] Closes: #43671
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2025-12-03 17:16:25 +01:00
committed by GitHub
parent dc30f3fd8e
commit 52f2561937
13 changed files with 376 additions and 33 deletions

View File

@@ -773,6 +773,7 @@ INITGUID
INITTOLOGFONTSTRUCT
INLINEPREFIX
inlines
Inno
INPC
inproc
INPUTHARDWARE
@@ -1848,6 +1849,7 @@ UNCPRIORITY
UNDNAME
UNICODETEXT
unins
Uninstaller
uninstalls
Uniquifies
unitconverter

View File

@@ -135,8 +135,9 @@ public partial class App : Application
try
{
var winget = new WinGetExtensionCommandsProvider();
var callback = allApps.LookupApp;
winget.SetAllLookup(callback);
winget.SetAllLookup(
query => allApps.LookupAppByPackageFamilyName(query, requireSingleMatch: true),
query => allApps.LookupAppByProductCode(query, requireSingleMatch: true));
services.AddSingleton<ICommandProvider>(winget);
}
catch (Exception ex)

View File

@@ -58,7 +58,7 @@ public class AllAppsCommandProviderTests : AppsTestBase
var provider = new AllAppsCommandProvider(page);
// Act
var result = provider.LookupApp(string.Empty);
var result = provider.LookupAppByDisplayName(string.Empty);
// Assert
Assert.IsNotNull(result);
@@ -77,7 +77,7 @@ public class AllAppsCommandProviderTests : AppsTestBase
await WaitForPageInitializationAsync();
// Act
var result = provider.LookupApp("TestApp");
var result = provider.LookupAppByDisplayName("TestApp");
// Assert
Assert.IsNotNull(result);
@@ -97,7 +97,7 @@ public class AllAppsCommandProviderTests : AppsTestBase
await WaitForPageInitializationAsync();
// Act
var result = provider.LookupApp("NonExistentApp");
var result = provider.LookupAppByDisplayName("NonExistentApp");
// Assert
Assert.IsNull(result);

View File

@@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Apps.Helpers;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.Properties;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CommandPalette.Extensions;
@@ -66,7 +68,71 @@ public partial class AllAppsCommandProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()];
public ICommandItem? LookupApp(string displayName)
public ICommandItem? LookupAppByPackageFamilyName(string packageFamilyName, bool requireSingleMatch)
{
if (string.IsNullOrEmpty(packageFamilyName))
{
return null;
}
var items = _page.GetItems();
List<ICommandItem> matches = [];
foreach (var item in items)
{
if (item is AppListItem appItem && string.Equals(packageFamilyName, appItem.App.PackageFamilyName, StringComparison.OrdinalIgnoreCase))
{
matches.Add(item);
if (!requireSingleMatch)
{
// Return early if we don't require uniqueness.
return item;
}
}
}
return requireSingleMatch && matches.Count == 1 ? matches[0] : null;
}
public ICommandItem? LookupAppByProductCode(string productCode, bool requireSingleMatch)
{
if (string.IsNullOrEmpty(productCode))
{
return null;
}
if (!UninstallRegistryAppLocator.TryGetInstallInfo(productCode, out _, out var candidates) || candidates.Count <= 0)
{
return null;
}
var items = _page.GetItems();
List<ICommandItem> matches = [];
foreach (var item in items)
{
if (item is not AppListItem appListItem || string.IsNullOrEmpty(appListItem.App.FullExecutablePath))
{
continue;
}
foreach (var candidate in candidates)
{
if (string.Equals(appListItem.App.FullExecutablePath, candidate, StringComparison.OrdinalIgnoreCase))
{
matches.Add(item);
if (!requireSingleMatch)
{
return item;
}
}
}
}
return requireSingleMatch && matches.Count == 1 ? matches[0] : null;
}
public ICommandItem? LookupAppByDisplayName(string displayName)
{
var items = _page.GetItems();

View File

@@ -29,6 +29,10 @@ public sealed class AppItem
public string AppIdentifier { get; set; } = string.Empty;
public string? PackageFamilyName { get; set; }
public string? FullExecutablePath { get; set; }
public AppItem()
{
}

View File

@@ -40,6 +40,8 @@ public sealed partial class AppListItem : ListItem
public string AppIdentifier => _app.AppIdentifier;
public AppItem App => _app;
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
{
Command = _appCommand = new AppCommand(app);
@@ -82,6 +84,12 @@ public sealed partial class AppListItem : ListItem
metadata.Add(new DetailsElement() { Key = "Path", Data = new DetailsLink() { Text = _app.ExePath } });
}
#if DEBUG
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 } });
#endif
// Icon
IconInfo? heroImage = null;
if (_app.IsPackaged)

View File

@@ -0,0 +1,205 @@
// 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 System.Linq;
using Microsoft.Win32;
namespace Microsoft.CmdPal.Ext.Apps.Helpers;
internal static class UninstallRegistryAppLocator
{
private static readonly string[] UninstallBaseKeys =
[
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
];
/// <summary>
/// Tries to find install directory and a list of plausible main EXEs from an uninstall key
/// (e.g. Inno Setup keys like "{guid}_is1").
/// <paramref name="exeCandidates"/> may be empty if we couldn't pick any safe EXEs.
/// </summary>
/// <returns>
/// Returns true if the uninstall key is found and an install directory is resolved.
/// </returns>
public static bool TryGetInstallInfo(
string uninstallKeyName,
out string? installDir,
out IReadOnlyList<string> exeCandidates,
string? expectedExeName = null)
{
installDir = null;
exeCandidates = [];
if (string.IsNullOrWhiteSpace(uninstallKeyName))
{
throw new ArgumentException("Key name must not be null or empty.", nameof(uninstallKeyName));
}
uninstallKeyName = uninstallKeyName.Trim();
foreach (var baseKeyPath in UninstallBaseKeys)
{
// HKLM
using (var key = Registry.LocalMachine.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}"))
{
if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates))
{
return true;
}
}
// HKCU
using (var key = Registry.CurrentUser.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}"))
{
if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates))
{
return true;
}
}
}
return false;
}
private static bool TryFromUninstallKey(
RegistryKey? key,
string? expectedExeName,
out string? installDir,
out IReadOnlyList<string> exeCandidates)
{
installDir = null;
exeCandidates = [];
if (key is null)
{
return false;
}
var location = (key.GetValue("InstallLocation") as string)?.Trim('"', ' ', '\t');
if (string.IsNullOrEmpty(location))
{
location = (key.GetValue("Inno Setup: App Path") as string)?.Trim('"', ' ', '\t');
}
if (string.IsNullOrEmpty(location))
{
var uninstall = key.GetValue("UninstallString") as string;
var uninsExe = ExtractFirstPath(uninstall);
if (!string.IsNullOrEmpty(uninsExe))
{
var dir = Path.GetDirectoryName(uninsExe);
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
{
location = dir;
}
}
}
if (string.IsNullOrEmpty(location) || !Directory.Exists(location))
{
return false;
}
installDir = location;
// Collect safe EXE candidates; may be empty if ambiguous or only uninstall exes exist.
exeCandidates = GetExeCandidates(location, expectedExeName);
return true;
}
private static IReadOnlyList<string> GetExeCandidates(string root, string? expectedExeName)
{
// Look at root and a "bin" subfolder (very common pattern)
var allExes = Directory.EnumerateFiles(root, "*.exe", SearchOption.TopDirectoryOnly)
.Concat(GetBinExes(root))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (allExes.Length == 0)
{
return [];
}
var result = new List<string>();
// 1) Exact match on expected exe name (if provided), ignoring case, and not uninstall/setup-like.
if (!string.IsNullOrWhiteSpace(expectedExeName))
{
foreach (var exe in allExes)
{
if (string.Equals(Path.GetFileName(exe), expectedExeName, StringComparison.OrdinalIgnoreCase) &&
!LooksLikeUninstallerOrSetup(exe))
{
result.Add(exe);
}
}
}
// 2) All other non-uninstall/setup exes
foreach (var exe in allExes)
{
if (LooksLikeUninstallerOrSetup(exe))
{
continue;
}
// Skip ones already added as expectedExeName matches
if (result.Contains(exe, StringComparer.OrdinalIgnoreCase))
{
continue;
}
result.Add(exe);
}
// 3) We intentionally do NOT add uninstall/setup/update exes here.
// If you ever want them, you can add a separate API to expose them.
return result;
}
private static IEnumerable<string> GetBinExes(string root)
{
var bin = Path.Combine(root, "bin");
return !Directory.Exists(bin)
? []
: Directory.EnumerateFiles(bin, "*.exe", SearchOption.TopDirectoryOnly);
}
private static bool LooksLikeUninstallerOrSetup(string path)
{
var name = Path.GetFileName(path);
return name.StartsWith("unins", StringComparison.OrdinalIgnoreCase) // e.g. Inno: unins000.exe
|| name.Contains("setup", StringComparison.OrdinalIgnoreCase) // setup.exe
|| name.Contains("installer", StringComparison.OrdinalIgnoreCase) // installer.exe / MyAppInstaller.exe
|| name.Contains("update", StringComparison.OrdinalIgnoreCase); // updater/updater.exe
}
private static string? ExtractFirstPath(string? commandLine)
{
if (string.IsNullOrWhiteSpace(commandLine))
{
return null;
}
commandLine = commandLine.Trim();
if (commandLine.StartsWith('"'))
{
var endQuote = commandLine.IndexOf('"', 1);
if (endQuote > 1)
{
return commandLine[1..endQuote];
}
}
var firstSpace = commandLine.IndexOf(' ');
var candidate = firstSpace > 0 ? commandLine[..firstSpace] : commandLine;
candidate = candidate.Trim('"');
return candidate.Length > 0 ? candidate : null;
}
}

View File

@@ -558,6 +558,7 @@ public class UWPApplication : IUWPApplication
IsPackaged = true,
Commands = app.GetCommands(),
AppIdentifier = app.GetAppIdentifier(),
PackageFamilyName = app.Package.FamilyName,
};
return item;
}

View File

@@ -1065,6 +1065,7 @@ public class Win32Program : IProgram
DirPath = app.Location,
Commands = app.GetCommands(),
AppIdentifier = app.GetAppIdentifier(),
FullExecutablePath = app.FullPath,
};
}
}

View File

@@ -62,7 +62,7 @@ public partial class InstallPackageCommand : InvokableCommand
{
PackageInstallCommandState.Install => Icons.DownloadIcon,
PackageInstallCommandState.Update => Icons.UpdateIcon,
PackageInstallCommandState.Uninstall => Icons.CompletedIcon,
PackageInstallCommandState.Uninstall => Icons.DeleteIcon,
_ => throw new NotImplementedException(),
};
Name = InstallCommandState switch

View File

@@ -194,46 +194,95 @@ public partial class InstallPackageListItem : ListItem
var isInstalled = _package.InstalledVersion is not null;
var installedState = isInstalled ?
(_package.IsUpdateAvailable ?
PackageInstallCommandState.Update : PackageInstallCommandState.Uninstall) :
(_package.IsUpdateAvailable ? PackageInstallCommandState.Update : PackageInstallCommandState.Uninstall) :
PackageInstallCommandState.Install;
// might be an uninstall command
InstallPackageCommand installCommand = new(_package, installedState);
if (isInstalled)
if (_package.InstalledVersion is not null)
{
this.Icon = installCommand.Icon;
this.Command = new NoOpCommand();
#if DEBUG
var installerType = _package.InstalledVersion.GetMetadata(PackageVersionMetadataField.InstallerType);
Subtitle = installerType + " | " + Subtitle;
#endif
List<IContextItem> contextMenu = [];
CommandContextItem uninstallContextItem = new(installCommand)
Command = installCommand;
Icon = installedState switch
{
IsCritical = true,
Icon = Icons.DeleteIcon,
PackageInstallCommandState.Install => Icons.DownloadIcon,
PackageInstallCommandState.Update => Icons.UpdateIcon,
PackageInstallCommandState.Uninstall => Icons.CompletedIcon,
_ => Icons.DownloadIcon,
};
if (WinGetStatics.AppSearchCallback is not null)
TryLocateAndAppendActionForApp(contextMenu);
MoreCommands = contextMenu.ToArray();
}
else
{
_installCommand = new InstallPackageCommand(_package, installedState);
_installCommand.InstallStateChanged += InstallStateChangedHandler;
Command = _installCommand;
Icon = _installCommand.Icon;
}
}
private void TryLocateAndAppendActionForApp(List<IContextItem> contextMenu)
{
try
{
// Let's try to connect it to an installed app if possible
// This is a bit of dark magic, since there's no direct link between
// WinGet packages and installed apps.
var lookupByPackageName = WinGetStatics.AppSearchByPackageFamilyNameCallback;
if (lookupByPackageName is not null)
{
var callback = WinGetStatics.AppSearchCallback;
var installedApp = callback(_package.DefaultInstallVersion is null ? _package.Name : _package.DefaultInstallVersion.DisplayName);
if (installedApp is not null)
var names = _package.InstalledVersion.PackageFamilyNames;
for (var i = 0; i < names.Count; i++)
{
this.Command = installedApp.Command;
contextMenu = [.. installedApp.MoreCommands];
var installedAppByPfn = lookupByPackageName(names[i]);
if (installedAppByPfn is not null)
{
contextMenu.Add(new Separator());
contextMenu.Add(new CommandContextItem(installedAppByPfn.Command));
foreach (var item in installedAppByPfn.MoreCommands)
{
contextMenu.Add(item);
}
return;
}
}
}
contextMenu.Add(uninstallContextItem);
this.MoreCommands = contextMenu.ToArray();
return;
var lookupByProductCode = WinGetStatics.AppSearchByProductCodeCallback;
if (lookupByProductCode is not null)
{
var productCodes = _package.InstalledVersion.ProductCodes;
for (var i = 0; i < productCodes.Count; i++)
{
var installedAppByProductCode = lookupByProductCode(productCodes[i]);
if (installedAppByProductCode is not null)
{
contextMenu.Add(new Separator());
contextMenu.Add(new CommandContextItem(installedAppByProductCode.Command));
foreach (var item in installedAppByProductCode.MoreCommands)
{
contextMenu.Add(item);
}
return;
}
}
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to retrieve app context menu items for package '{_package?.Name ?? "Unknown"}'", ex);
}
// didn't find the app
_installCommand = new InstallPackageCommand(_package, installedState);
this.Command = _installCommand;
Icon = _installCommand.Icon;
_installCommand.InstallStateChanged += InstallStateChangedHandler;
}
private void InstallStateChangedHandler(object? sender, InstallPackageCommand e)

View File

@@ -41,5 +41,9 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider
public override void InitializeWithHost(IExtensionHost host) => WinGetExtensionHost.Instance.Initialize(host);
public void SetAllLookup(Func<string, ICommandItem?> callback) => WinGetStatics.AppSearchCallback = callback;
public void SetAllLookup(Func<string, ICommandItem?> lookupByPackageName, Func<string, ICommandItem?> lookupByProductCode)
{
WinGetStatics.AppSearchByPackageFamilyNameCallback = lookupByPackageName;
WinGetStatics.AppSearchByProductCodeCallback = lookupByProductCode;
}
}

View File

@@ -34,7 +34,9 @@ internal static class WinGetStatics
private static readonly StatusMessage _errorMessage = new() { State = MessageState.Error };
public static Func<string, ICommandItem?>? AppSearchCallback { get; set; }
public static Func<string, ICommandItem?>? AppSearchByPackageFamilyNameCallback { get; set; }
public static Func<string, ICommandItem?>? AppSearchByProductCodeCallback { get; set; }
private static readonly CompositeFormat CreateCatalogErrorMessage = System.Text.CompositeFormat.Parse(Properties.Resources.winget_create_catalog_error);