mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-15 19:27:56 +01:00
Merge remote-tracking branch 'origin/main' into dev/vanzue/cmdpal-pt
This commit is contained in:
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -774,6 +774,7 @@ INITGUID
|
||||
INITTOLOGFONTSTRUCT
|
||||
INLINEPREFIX
|
||||
inlines
|
||||
Inno
|
||||
INPC
|
||||
inproc
|
||||
INPUTHARDWARE
|
||||
@@ -1849,6 +1850,7 @@ UNCPRIORITY
|
||||
UNDNAME
|
||||
UNICODETEXT
|
||||
unins
|
||||
Uninstaller
|
||||
uninstalls
|
||||
Uniquifies
|
||||
unitconverter
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -558,6 +558,7 @@ public class UWPApplication : IUWPApplication
|
||||
IsPackaged = true,
|
||||
Commands = app.GetCommands(),
|
||||
AppIdentifier = app.GetAppIdentifier(),
|
||||
PackageFamilyName = app.Package.FamilyName,
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1065,6 +1065,7 @@ public class Win32Program : IProgram
|
||||
DirPath = app.Location,
|
||||
Commands = app.GetCommands(),
|
||||
AppIdentifier = app.GetAppIdentifier(),
|
||||
FullExecutablePath = app.FullPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user