Skip to content

Commit 146926c

Browse files
committed
Merge remote-tracking branch 'origin/main' into dev/snickler/net10-upgrade
2 parents 8a36ec5 + 9dcddfd commit 146926c

File tree

17 files changed

+422
-38
lines changed

17 files changed

+422
-38
lines changed

.github/actions/spell-check/expect.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,7 @@ INITGUID
773773
INITTOLOGFONTSTRUCT
774774
INLINEPREFIX
775775
inlines
776+
Inno
776777
INPC
777778
inproc
778779
INPUTHARDWARE
@@ -1848,6 +1849,7 @@ UNCPRIORITY
18481849
UNDNAME
18491850
UNICODETEXT
18501851
unins
1852+
Uninstaller
18511853
uninstalls
18521854
Uniquifies
18531855
unitconverter

src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ private static ServiceProvider ConfigureServices()
135135
try
136136
{
137137
var winget = new WinGetExtensionCommandsProvider();
138-
var callback = allApps.LookupApp;
139-
winget.SetAllLookup(callback);
138+
winget.SetAllLookup(
139+
query => allApps.LookupAppByPackageFamilyName(query, requireSingleMatch: true),
140+
query => allApps.LookupAppByProductCode(query, requireSingleMatch: true));
140141
services.AddSingleton<ICommandProvider>(winget);
141142
}
142143
catch (Exception ex)

src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public void LookupAppWithEmptyNameReturnsNotNull()
5858
var provider = new AllAppsCommandProvider(page);
5959

6060
// Act
61-
var result = provider.LookupApp(string.Empty);
61+
var result = provider.LookupAppByDisplayName(string.Empty);
6262

6363
// Assert
6464
Assert.IsNotNull(result);
@@ -77,7 +77,7 @@ public async Task ProviderWithMockData_LookupApp_ReturnsCorrectApp()
7777
await WaitForPageInitializationAsync();
7878

7979
// Act
80-
var result = provider.LookupApp("TestApp");
80+
var result = provider.LookupAppByDisplayName("TestApp");
8181

8282
// Assert
8383
Assert.IsNotNull(result);
@@ -97,7 +97,7 @@ public async Task ProviderWithMockData_LookupApp_ReturnsNullForNonExistentApp()
9797
await WaitForPageInitializationAsync();
9898

9999
// Act
100-
var result = provider.LookupApp("NonExistentApp");
100+
var result = provider.LookupAppByDisplayName("NonExistentApp");
101101

102102
// Assert
103103
Assert.IsNull(result);

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
using System;
66
using System.Collections.Generic;
7+
using Microsoft.CmdPal.Ext.Apps.Helpers;
8+
using Microsoft.CmdPal.Ext.Apps.Programs;
79
using Microsoft.CmdPal.Ext.Apps.Properties;
810
using Microsoft.CmdPal.Ext.Apps.State;
911
using Microsoft.CommandPalette.Extensions;
@@ -66,7 +68,71 @@ public static int TopLevelResultLimit
6668

6769
public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()];
6870

69-
public ICommandItem? LookupApp(string displayName)
71+
public ICommandItem? LookupAppByPackageFamilyName(string packageFamilyName, bool requireSingleMatch)
72+
{
73+
if (string.IsNullOrEmpty(packageFamilyName))
74+
{
75+
return null;
76+
}
77+
78+
var items = _page.GetItems();
79+
List<ICommandItem> matches = [];
80+
81+
foreach (var item in items)
82+
{
83+
if (item is AppListItem appItem && string.Equals(packageFamilyName, appItem.App.PackageFamilyName, StringComparison.OrdinalIgnoreCase))
84+
{
85+
matches.Add(item);
86+
if (!requireSingleMatch)
87+
{
88+
// Return early if we don't require uniqueness.
89+
return item;
90+
}
91+
}
92+
}
93+
94+
return requireSingleMatch && matches.Count == 1 ? matches[0] : null;
95+
}
96+
97+
public ICommandItem? LookupAppByProductCode(string productCode, bool requireSingleMatch)
98+
{
99+
if (string.IsNullOrEmpty(productCode))
100+
{
101+
return null;
102+
}
103+
104+
if (!UninstallRegistryAppLocator.TryGetInstallInfo(productCode, out _, out var candidates) || candidates.Count <= 0)
105+
{
106+
return null;
107+
}
108+
109+
var items = _page.GetItems();
110+
List<ICommandItem> matches = [];
111+
112+
foreach (var item in items)
113+
{
114+
if (item is not AppListItem appListItem || string.IsNullOrEmpty(appListItem.App.FullExecutablePath))
115+
{
116+
continue;
117+
}
118+
119+
foreach (var candidate in candidates)
120+
{
121+
if (string.Equals(appListItem.App.FullExecutablePath, candidate, StringComparison.OrdinalIgnoreCase))
122+
{
123+
matches.Add(item);
124+
if (!requireSingleMatch)
125+
{
126+
return item;
127+
}
128+
}
129+
}
130+
}
131+
132+
return requireSingleMatch && matches.Count == 1 ? matches[0] : null;
133+
}
134+
135+
public ICommandItem? LookupAppByDisplayName(string displayName)
70136
{
71137
var items = _page.GetItems();
72138

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public sealed class AppItem
2929

3030
public string AppIdentifier { get; set; } = string.Empty;
3131

32+
public string? PackageFamilyName { get; set; }
33+
34+
public string? FullExecutablePath { get; set; }
35+
3236
public AppItem()
3337
{
3438
}

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public override IIconInfo? Icon
4040

4141
public string AppIdentifier => _app.AppIdentifier;
4242

43+
public AppItem App => _app;
44+
4345
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
4446
{
4547
Command = _appCommand = new AppCommand(app);
@@ -82,6 +84,12 @@ private async Task<Details> BuildDetails()
8284
metadata.Add(new DetailsElement() { Key = "Path", Data = new DetailsLink() { Text = _app.ExePath } });
8385
}
8486

87+
#if DEBUG
88+
metadata.Add(new DetailsElement() { Key = "[DEBUG] AppIdentifier", Data = new DetailsLink() { Text = _app.AppIdentifier } });
89+
metadata.Add(new DetailsElement() { Key = "[DEBUG] ExePath", Data = new DetailsLink() { Text = _app.ExePath } });
90+
metadata.Add(new DetailsElement() { Key = "[DEBUG] IcoPath", Data = new DetailsLink() { Text = _app.IcoPath } });
91+
#endif
92+
8593
// Icon
8694
IconInfo? heroImage = null;
8795
if (_app.IsPackaged)
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) Microsoft Corporation
2+
// The Microsoft Corporation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.IO;
8+
using System.Linq;
9+
using Microsoft.Win32;
10+
11+
namespace Microsoft.CmdPal.Ext.Apps.Helpers;
12+
13+
internal static class UninstallRegistryAppLocator
14+
{
15+
private static readonly string[] UninstallBaseKeys =
16+
[
17+
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
18+
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
19+
];
20+
21+
/// <summary>
22+
/// Tries to find install directory and a list of plausible main EXEs from an uninstall key
23+
/// (e.g. Inno Setup keys like "{guid}_is1").
24+
/// <paramref name="exeCandidates"/> may be empty if we couldn't pick any safe EXEs.
25+
/// </summary>
26+
/// <returns>
27+
/// Returns true if the uninstall key is found and an install directory is resolved.
28+
/// </returns>
29+
public static bool TryGetInstallInfo(
30+
string uninstallKeyName,
31+
out string? installDir,
32+
out IReadOnlyList<string> exeCandidates,
33+
string? expectedExeName = null)
34+
{
35+
installDir = null;
36+
exeCandidates = [];
37+
38+
if (string.IsNullOrWhiteSpace(uninstallKeyName))
39+
{
40+
throw new ArgumentException("Key name must not be null or empty.", nameof(uninstallKeyName));
41+
}
42+
43+
uninstallKeyName = uninstallKeyName.Trim();
44+
45+
foreach (var baseKeyPath in UninstallBaseKeys)
46+
{
47+
// HKLM
48+
using (var key = Registry.LocalMachine.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}"))
49+
{
50+
if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates))
51+
{
52+
return true;
53+
}
54+
}
55+
56+
// HKCU
57+
using (var key = Registry.CurrentUser.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}"))
58+
{
59+
if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates))
60+
{
61+
return true;
62+
}
63+
}
64+
}
65+
66+
return false;
67+
}
68+
69+
private static bool TryFromUninstallKey(
70+
RegistryKey? key,
71+
string? expectedExeName,
72+
out string? installDir,
73+
out IReadOnlyList<string> exeCandidates)
74+
{
75+
installDir = null;
76+
exeCandidates = [];
77+
78+
if (key is null)
79+
{
80+
return false;
81+
}
82+
83+
var location = (key.GetValue("InstallLocation") as string)?.Trim('"', ' ', '\t');
84+
if (string.IsNullOrEmpty(location))
85+
{
86+
location = (key.GetValue("Inno Setup: App Path") as string)?.Trim('"', ' ', '\t');
87+
}
88+
89+
if (string.IsNullOrEmpty(location))
90+
{
91+
var uninstall = key.GetValue("UninstallString") as string;
92+
var uninsExe = ExtractFirstPath(uninstall);
93+
if (!string.IsNullOrEmpty(uninsExe))
94+
{
95+
var dir = Path.GetDirectoryName(uninsExe);
96+
if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir))
97+
{
98+
location = dir;
99+
}
100+
}
101+
}
102+
103+
if (string.IsNullOrEmpty(location) || !Directory.Exists(location))
104+
{
105+
return false;
106+
}
107+
108+
installDir = location;
109+
110+
// Collect safe EXE candidates; may be empty if ambiguous or only uninstall exes exist.
111+
exeCandidates = GetExeCandidates(location, expectedExeName);
112+
return true;
113+
}
114+
115+
private static IReadOnlyList<string> GetExeCandidates(string root, string? expectedExeName)
116+
{
117+
// Look at root and a "bin" subfolder (very common pattern)
118+
var allExes = Directory.EnumerateFiles(root, "*.exe", SearchOption.TopDirectoryOnly)
119+
.Concat(GetBinExes(root))
120+
.Distinct(StringComparer.OrdinalIgnoreCase)
121+
.ToArray();
122+
123+
if (allExes.Length == 0)
124+
{
125+
return [];
126+
}
127+
128+
var result = new List<string>();
129+
130+
// 1) Exact match on expected exe name (if provided), ignoring case, and not uninstall/setup-like.
131+
if (!string.IsNullOrWhiteSpace(expectedExeName))
132+
{
133+
foreach (var exe in allExes)
134+
{
135+
if (string.Equals(Path.GetFileName(exe), expectedExeName, StringComparison.OrdinalIgnoreCase) &&
136+
!LooksLikeUninstallerOrSetup(exe))
137+
{
138+
result.Add(exe);
139+
}
140+
}
141+
}
142+
143+
// 2) All other non-uninstall/setup exes
144+
foreach (var exe in allExes)
145+
{
146+
if (LooksLikeUninstallerOrSetup(exe))
147+
{
148+
continue;
149+
}
150+
151+
// Skip ones already added as expectedExeName matches
152+
if (result.Contains(exe, StringComparer.OrdinalIgnoreCase))
153+
{
154+
continue;
155+
}
156+
157+
result.Add(exe);
158+
}
159+
160+
// 3) We intentionally do NOT add uninstall/setup/update exes here.
161+
// If you ever want them, you can add a separate API to expose them.
162+
return result;
163+
}
164+
165+
private static IEnumerable<string> GetBinExes(string root)
166+
{
167+
var bin = Path.Combine(root, "bin");
168+
return !Directory.Exists(bin)
169+
? []
170+
: Directory.EnumerateFiles(bin, "*.exe", SearchOption.TopDirectoryOnly);
171+
}
172+
173+
private static bool LooksLikeUninstallerOrSetup(string path)
174+
{
175+
var name = Path.GetFileName(path);
176+
return name.StartsWith("unins", StringComparison.OrdinalIgnoreCase) // e.g. Inno: unins000.exe
177+
|| name.Contains("setup", StringComparison.OrdinalIgnoreCase) // setup.exe
178+
|| name.Contains("installer", StringComparison.OrdinalIgnoreCase) // installer.exe / MyAppInstaller.exe
179+
|| name.Contains("update", StringComparison.OrdinalIgnoreCase); // updater/updater.exe
180+
}
181+
182+
private static string? ExtractFirstPath(string? commandLine)
183+
{
184+
if (string.IsNullOrWhiteSpace(commandLine))
185+
{
186+
return null;
187+
}
188+
189+
commandLine = commandLine.Trim();
190+
191+
if (commandLine.StartsWith('"'))
192+
{
193+
var endQuote = commandLine.IndexOf('"', 1);
194+
if (endQuote > 1)
195+
{
196+
return commandLine[1..endQuote];
197+
}
198+
}
199+
200+
var firstSpace = commandLine.IndexOf(' ');
201+
var candidate = firstSpace > 0 ? commandLine[..firstSpace] : commandLine;
202+
candidate = candidate.Trim('"');
203+
return candidate.Length > 0 ? candidate : null;
204+
}
205+
}

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ public AppItem ToAppItem()
558558
IsPackaged = true,
559559
Commands = app.GetCommands(),
560560
AppIdentifier = app.GetAppIdentifier(),
561+
PackageFamilyName = app.Package.FamilyName,
561562
};
562563
return item;
563564
}

src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,7 @@ internal AppItem ToAppItem()
10661066
DirPath = app.Location,
10671067
Commands = app.GetCommands(),
10681068
AppIdentifier = app.GetAppIdentifier(),
1069+
FullExecutablePath = app.FullPath,
10691070
};
10701071
}
10711072
}

0 commit comments

Comments
 (0)