Skip to content

Commit f49ae07

Browse files
authored
Find the system WinGet when it is not resolvable through PATH (#5359)
1 parent 9967607 commit f49ae07

3 files changed

Lines changed: 284 additions & 2 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using Microsoft.Win32;
2+
using UniGetUI.Core.Logging;
3+
4+
namespace UniGetUI.PackageEngine.Managers.WingetManager;
5+
6+
internal static class SystemWinGetLocator
7+
{
8+
private const string WinGetExecutableName = "winget.exe";
9+
private const string AppInstallerPackageName = "Microsoft.DesktopAppInstaller";
10+
private const string AppInstallerPublisherId = "8wekyb3d8bbwe";
11+
12+
private const string AppxRepositoryKey =
13+
@"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
14+
15+
public static IEnumerable<string> EnumerateOffPathExecutables(Func<string, bool> fileExists)
16+
{
17+
return EnumerateOffPathExecutables(
18+
fileExists,
19+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
20+
ReadAppInstallerInstallDirectories
21+
);
22+
}
23+
24+
internal static IEnumerable<string> EnumerateOffPathExecutables(
25+
Func<string, bool> fileExists,
26+
string localAppDataDirectory,
27+
Func<IReadOnlyList<string>> readAppInstallerInstallDirectories
28+
)
29+
{
30+
foreach (
31+
string directory in EnumerateCandidateDirectories(
32+
localAppDataDirectory,
33+
readAppInstallerInstallDirectories
34+
)
35+
)
36+
{
37+
string candidate = Path.Join(directory, WinGetExecutableName);
38+
if (fileExists(candidate))
39+
{
40+
yield return candidate;
41+
}
42+
}
43+
}
44+
45+
private static IEnumerable<string> EnumerateCandidateDirectories(
46+
string localAppDataDirectory,
47+
Func<IReadOnlyList<string>> readAppInstallerInstallDirectories
48+
)
49+
{
50+
IReadOnlyList<string> installDirectories = readAppInstallerInstallDirectories();
51+
if (installDirectories.Count is 0)
52+
{
53+
yield break;
54+
}
55+
56+
if (!string.IsNullOrWhiteSpace(localAppDataDirectory))
57+
{
58+
yield return Path.Join(localAppDataDirectory, "Microsoft", "WindowsApps");
59+
}
60+
61+
foreach (string directory in installDirectories)
62+
{
63+
yield return directory;
64+
}
65+
}
66+
67+
internal static IReadOnlyList<string> ReadAppInstallerInstallDirectories()
68+
{
69+
List<(Version Version, string Directory)> matches = [];
70+
71+
try
72+
{
73+
using var root = Registry.CurrentUser.OpenSubKey(AppxRepositoryKey);
74+
if (root is null)
75+
{
76+
return [];
77+
}
78+
79+
foreach (string packageFullName in root.GetSubKeyNames())
80+
{
81+
if (!IsAppInstallerPackageFullName(packageFullName))
82+
{
83+
continue;
84+
}
85+
86+
try
87+
{
88+
using var entry = root.OpenSubKey(packageFullName);
89+
if (
90+
entry?.GetValue("PackageRootFolder") is not string directory
91+
|| string.IsNullOrWhiteSpace(directory)
92+
)
93+
{
94+
continue;
95+
}
96+
97+
matches.Add((ParsePackageVersion(packageFullName), directory));
98+
}
99+
catch
100+
{
101+
continue;
102+
}
103+
}
104+
}
105+
catch (Exception ex)
106+
{
107+
Logger.Debug(
108+
$"Could not read the App Installer install location from the registry: {ex.Message}"
109+
);
110+
return [];
111+
}
112+
113+
return matches
114+
.OrderByDescending(match => match.Version)
115+
.Select(match => match.Directory)
116+
.ToArray();
117+
}
118+
119+
internal static bool IsAppInstallerPackageFullName(string packageFullName)
120+
{
121+
string[] pieces = packageFullName.Split('_');
122+
return pieces.Length >= 4
123+
&& pieces[0].Equals(AppInstallerPackageName, StringComparison.OrdinalIgnoreCase)
124+
&& pieces[^1].Equals(AppInstallerPublisherId, StringComparison.OrdinalIgnoreCase);
125+
}
126+
127+
internal static Version ParsePackageVersion(string packageFullName)
128+
{
129+
string[] pieces = packageFullName.Split('_');
130+
return pieces.Length >= 2 && Version.TryParse(pieces[1], out Version? version)
131+
? version
132+
: new Version(0, 0);
133+
}
134+
}

src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,22 +332,25 @@ public override IReadOnlyList<string> FindCandidateExecutableFiles()
332332
executableName => CoreTools.WhichMultiple(executableName),
333333
File.Exists,
334334
GetBundledPingetExecutablePath(),
335-
GetCliToolPreference()
335+
GetCliToolPreference(),
336+
() => SystemWinGetLocator.EnumerateOffPathExecutables(File.Exists)
336337
);
337338
}
338339

339340
internal static IReadOnlyList<string> FindCandidateExecutableFiles(
340341
Func<string, IReadOnlyList<string>> findExecutables,
341342
Func<string, bool> fileExists,
342343
string bundledPingetPath,
343-
WinGetCliToolPreference cliToolPreference = WinGetCliToolPreference.Default
344+
WinGetCliToolPreference cliToolPreference = WinGetCliToolPreference.Default,
345+
Func<IEnumerable<string>>? findOffPathSystemWinGetFiles = null
344346
)
345347
{
346348
List<string> candidates = [];
347349

348350
if (cliToolPreference is not WinGetCliToolPreference.BundledPinget)
349351
{
350352
candidates.AddRange(findExecutables(SystemWinGetExecutableName));
353+
candidates.AddRange(findOffPathSystemWinGetFiles?.Invoke() ?? []);
351354
}
352355

353356
if (cliToolPreference is not WinGetCliToolPreference.SystemWinGet)

src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,147 @@ public void FindCandidateExecutableFilesReturnsEmptyWhenNoCliToolExists()
311311
Assert.Empty(candidates);
312312
}
313313

314+
[Fact]
315+
public void FindCandidateExecutableFilesPrefersOffPathSystemWinGetOverBundledPinget()
316+
{
317+
const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe";
318+
const string packagedWinGet =
319+
@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe\winget.exe";
320+
321+
var candidates = WinGet.FindCandidateExecutableFiles(
322+
static _ => [],
323+
path => path == bundledPinget,
324+
bundledPinget,
325+
WinGetCliToolPreference.Default,
326+
static () => [packagedWinGet]
327+
);
328+
329+
Assert.Equal([packagedWinGet, bundledPinget], candidates);
330+
}
331+
332+
[Fact]
333+
public void FindCandidateExecutableFilesDeduplicatesOffPathSystemWinGetAlreadyFoundOnPath()
334+
{
335+
const string systemWinGet = @"C:\WindowsApps\winget.exe";
336+
const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe";
337+
338+
var candidates = WinGet.FindCandidateExecutableFiles(
339+
static executableName => executableName == "winget.exe" ? [systemWinGet] : [],
340+
path => path == bundledPinget,
341+
bundledPinget,
342+
WinGetCliToolPreference.Default,
343+
static () => [systemWinGet]
344+
);
345+
346+
Assert.Equal([systemWinGet, bundledPinget], candidates);
347+
}
348+
349+
[Fact]
350+
public void FindCandidateExecutableFilesIgnoresOffPathSystemWinGetInPingetMode()
351+
{
352+
const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe";
353+
354+
var candidates = WinGet.FindCandidateExecutableFiles(
355+
static _ => [],
356+
path => path == bundledPinget,
357+
bundledPinget,
358+
WinGetCliToolPreference.BundledPinget,
359+
static () =>
360+
throw new InvalidOperationException(
361+
"System WinGet should not be queried in Pinget mode."
362+
)
363+
);
364+
365+
Assert.Equal([bundledPinget], candidates);
366+
}
367+
368+
[Fact]
369+
public void EnumerateOffPathExecutablesReturnsExecutionAliasAndAppInstallerLocations()
370+
{
371+
const string localAppData = @"C:\Users\test\AppData\Local";
372+
string alias = Path.Join(localAppData, "Microsoft", "WindowsApps", "winget.exe");
373+
const string packageRoot =
374+
@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe";
375+
string packagedWinGet = Path.Join(packageRoot, "winget.exe");
376+
377+
var executables = SystemWinGetLocator
378+
.EnumerateOffPathExecutables(
379+
path => path == alias || path == packagedWinGet,
380+
localAppData,
381+
() => [packageRoot]
382+
)
383+
.ToArray();
384+
385+
Assert.Equal([alias, packagedWinGet], executables);
386+
}
387+
388+
[Fact]
389+
public void EnumerateOffPathExecutablesSkipsDirectoriesWithoutWinGet()
390+
{
391+
const string packageRoot =
392+
@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe";
393+
string packagedWinGet = Path.Join(packageRoot, "winget.exe");
394+
395+
var executables = SystemWinGetLocator
396+
.EnumerateOffPathExecutables(
397+
path => path == packagedWinGet,
398+
@"C:\Users\test\AppData\Local",
399+
() => [@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_0.0.0.0_x64__8wekyb3d8bbwe", packageRoot]
400+
)
401+
.ToArray();
402+
403+
Assert.Equal([packagedWinGet], executables);
404+
}
405+
406+
[Fact]
407+
public void EnumerateOffPathExecutablesIgnoresTheExecutionAliasWhenAppInstallerIsNotRegistered()
408+
{
409+
const string localAppData = @"C:\Users\test\AppData\Local";
410+
string alias = Path.Join(localAppData, "Microsoft", "WindowsApps", "winget.exe");
411+
412+
var executables = SystemWinGetLocator
413+
.EnumerateOffPathExecutables(path => path == alias, localAppData, static () => [])
414+
.ToArray();
415+
416+
Assert.Empty(executables);
417+
}
418+
419+
[Theory]
420+
[InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", true)]
421+
[InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_neutral_split.scale-100_8wekyb3d8bbwe", true)]
422+
[InlineData("Microsoft.DesktopAppInstaller_9.9.9.0_x64__1abcdefghijkl", false)]
423+
[InlineData("Microsoft.DesktopAppInstallerExtra_1.0.0.0_x64__8wekyb3d8bbwe", false)]
424+
[InlineData("Contoso.DesktopAppInstaller_1.0.0.0_x64__8wekyb3d8bbwe", false)]
425+
[InlineData("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", false)]
426+
[InlineData("Microsoft.DesktopAppInstaller_8wekyb3d8bbwe", false)]
427+
[InlineData("Microsoft.DesktopAppInstaller", false)]
428+
public void IsAppInstallerPackageFullNameRequiresTheMicrosoftPublisherId(
429+
string packageFullName,
430+
bool expected
431+
)
432+
{
433+
Assert.Equal(
434+
expected,
435+
SystemWinGetLocator.IsAppInstallerPackageFullName(packageFullName)
436+
);
437+
}
438+
439+
[Theory]
440+
[InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", "1.29.290.0")]
441+
[InlineData("Microsoft.DesktopAppInstaller_1.2_neutral__8wekyb3d8bbwe", "1.2")]
442+
[InlineData("Microsoft.DesktopAppInstaller", "0.0")]
443+
[InlineData("Microsoft.DesktopAppInstaller_notaversion_x64__8wekyb3d8bbwe", "0.0")]
444+
public void ParsePackageVersionReadsTheVersionPieceOfThePackageFullName(
445+
string packageFullName,
446+
string expected
447+
)
448+
{
449+
Assert.Equal(
450+
Version.Parse(expected),
451+
SystemWinGetLocator.ParsePackageVersion(packageFullName)
452+
);
453+
}
454+
314455
[Fact]
315456
public void PingetCliHelperDeserializesListResponsesWithGeneratedContext()
316457
{
@@ -424,6 +565,10 @@ int expectedPreference
424565
[InlineData(@"C:\Program Files\UniGetUI\pinget.exe", 1)]
425566
[InlineData(@"C:\Tools\pinget.exe", 1)]
426567
[InlineData(@"C:\WindowsApps\winget.exe", 0)]
568+
[InlineData(
569+
@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe\winget.exe",
570+
0
571+
)]
427572
public void GetCliToolKindRecognizesPingetExecutableName(
428573
string executablePath,
429574
int expectedKind

0 commit comments

Comments
 (0)