diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 14e7e3ccf7..5fa0e2563f 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -774,6 +774,7 @@ INITGUID INITTOLOGFONTSTRUCT INLINEPREFIX inlines +Inno INPC inproc INPUTHARDWARE @@ -1849,6 +1850,7 @@ UNCPRIORITY UNDNAME UNICODETEXT unins +Uninstaller uninstalls Uniquifies unitconverter diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index f91b9e304a..53f47286b2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -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(winget); } catch (Exception ex) diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs index e7fbc6859d..cc24433931 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs @@ -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); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 7232e955d7..317087847e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -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 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 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(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs index b7d01593cb..14f9597418 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs @@ -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() { } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs index e99ffae352..5689b70698 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -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) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs new file mode 100644 index 0000000000..8e59a26395 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs @@ -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", + ]; + + /// + /// Tries to find install directory and a list of plausible main EXEs from an uninstall key + /// (e.g. Inno Setup keys like "{guid}_is1"). + /// may be empty if we couldn't pick any safe EXEs. + /// + /// + /// Returns true if the uninstall key is found and an install directory is resolved. + /// + public static bool TryGetInstallInfo( + string uninstallKeyName, + out string? installDir, + out IReadOnlyList 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 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 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(); + + // 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 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; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 37698d972d..4ec9598483 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -558,6 +558,7 @@ public class UWPApplication : IUWPApplication IsPackaged = true, Commands = app.GetCommands(), AppIdentifier = app.GetAppIdentifier(), + PackageFamilyName = app.Package.FamilyName, }; return item; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs index 9b89afc425..fda8e2c697 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs @@ -1065,6 +1065,7 @@ public class Win32Program : IProgram DirPath = app.Location, Commands = app.GetCommands(), AppIdentifier = app.GetAppIdentifier(), + FullExecutablePath = app.FullPath, }; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs index d2c1ea7283..7dbe740d95 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs @@ -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 diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs index a8eda1bae9..1e1f337944 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs @@ -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 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 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) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs index 14c7ec0831..a2608ef8a8 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs @@ -41,5 +41,9 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider public override void InitializeWithHost(IExtensionHost host) => WinGetExtensionHost.Instance.Initialize(host); - public void SetAllLookup(Func callback) => WinGetStatics.AppSearchCallback = callback; + public void SetAllLookup(Func lookupByPackageName, Func lookupByProductCode) + { + WinGetStatics.AppSearchByPackageFamilyNameCallback = lookupByPackageName; + WinGetStatics.AppSearchByProductCodeCallback = lookupByProductCode; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs index da591c566c..001ba5539d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs @@ -34,7 +34,9 @@ internal static class WinGetStatics private static readonly StatusMessage _errorMessage = new() { State = MessageState.Error }; - public static Func? AppSearchCallback { get; set; } + public static Func? AppSearchByPackageFamilyNameCallback { get; set; } + + public static Func? AppSearchByProductCodeCallback { get; set; } private static readonly CompositeFormat CreateCatalogErrorMessage = System.Text.CompositeFormat.Parse(Properties.Resources.winget_create_catalog_error);