mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-16 03:37:59 +01:00
<!-- 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
206 lines
6.5 KiB
C#
206 lines
6.5 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;
|
|
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;
|
|
}
|
|
}
|