From e0a5a0d249afadbef7964378d7a9357a787922be Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 15:58:27 -0400 Subject: [PATCH 1/3] Find the system WinGet when it is not resolvable through PATH --- .../ClientHelpers/SystemWinGetLocator.cs | 130 ++++++++++++++++++ .../WinGet.cs | 7 +- .../WinGetManagerTests.cs | 125 +++++++++++++++++ 3 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs new file mode 100644 index 0000000000..1227df4f04 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs @@ -0,0 +1,130 @@ +using Microsoft.Win32; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Managers.WingetManager; + +internal static class SystemWinGetLocator +{ + private const string WinGetExecutableName = "winget.exe"; + private const string AppInstallerPackageNamePrefix = "Microsoft.DesktopAppInstaller_"; + + private const string AppxRepositoryKey = + @"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; + + public static IEnumerable EnumerateOffPathExecutables(Func fileExists) + { + return EnumerateOffPathExecutables( + fileExists, + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + ReadAppInstallerInstallDirectories + ); + } + + internal static IEnumerable EnumerateOffPathExecutables( + Func fileExists, + string localAppDataDirectory, + Func> readAppInstallerInstallDirectories + ) + { + foreach ( + string directory in EnumerateCandidateDirectories( + localAppDataDirectory, + readAppInstallerInstallDirectories + ) + ) + { + string candidate = Path.Join(directory, WinGetExecutableName); + if (fileExists(candidate)) + { + yield return candidate; + } + } + } + + private static IEnumerable EnumerateCandidateDirectories( + string localAppDataDirectory, + Func> readAppInstallerInstallDirectories + ) + { + IReadOnlyList installDirectories = readAppInstallerInstallDirectories(); + if (installDirectories.Count is 0) + { + yield break; + } + + if (!string.IsNullOrWhiteSpace(localAppDataDirectory)) + { + yield return Path.Join(localAppDataDirectory, "Microsoft", "WindowsApps"); + } + + foreach (string directory in installDirectories) + { + yield return directory; + } + } + + internal static IReadOnlyList ReadAppInstallerInstallDirectories() + { + List<(Version Version, string Directory)> matches = []; + + try + { + using var root = Registry.CurrentUser.OpenSubKey(AppxRepositoryKey); + if (root is null) + { + return []; + } + + foreach (string packageFullName in root.GetSubKeyNames()) + { + if ( + !packageFullName.StartsWith( + AppInstallerPackageNamePrefix, + StringComparison.OrdinalIgnoreCase + ) + ) + { + continue; + } + + try + { + using var entry = root.OpenSubKey(packageFullName); + if ( + entry?.GetValue("PackageRootFolder") is not string directory + || string.IsNullOrWhiteSpace(directory) + ) + { + continue; + } + + matches.Add((ParsePackageVersion(packageFullName), directory)); + } + catch + { + continue; + } + } + } + catch (Exception ex) + { + Logger.Debug( + $"Could not read the App Installer install location from the registry: {ex.Message}" + ); + return []; + } + + return matches + .OrderByDescending(match => match.Version) + .Select(match => match.Directory) + .ToArray(); + } + + internal static Version ParsePackageVersion(string packageFullName) + { + string[] pieces = packageFullName.Split('_'); + return pieces.Length >= 2 && Version.TryParse(pieces[1], out Version? version) + ? version + : new Version(0, 0); + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs index cca42b0e0f..a668c2e698 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/WinGet.cs @@ -332,7 +332,8 @@ public override IReadOnlyList FindCandidateExecutableFiles() executableName => CoreTools.WhichMultiple(executableName), File.Exists, GetBundledPingetExecutablePath(), - GetCliToolPreference() + GetCliToolPreference(), + () => SystemWinGetLocator.EnumerateOffPathExecutables(File.Exists) ); } @@ -340,7 +341,8 @@ internal static IReadOnlyList FindCandidateExecutableFiles( Func> findExecutables, Func fileExists, string bundledPingetPath, - WinGetCliToolPreference cliToolPreference = WinGetCliToolPreference.Default + WinGetCliToolPreference cliToolPreference = WinGetCliToolPreference.Default, + Func>? findOffPathSystemWinGetFiles = null ) { List candidates = []; @@ -348,6 +350,7 @@ internal static IReadOnlyList FindCandidateExecutableFiles( if (cliToolPreference is not WinGetCliToolPreference.BundledPinget) { candidates.AddRange(findExecutables(SystemWinGetExecutableName)); + candidates.AddRange(findOffPathSystemWinGetFiles?.Invoke() ?? []); } if (cliToolPreference is not WinGetCliToolPreference.SystemWinGet) diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index e8a1c1abb4..d4cd521f87 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -311,6 +311,127 @@ public void FindCandidateExecutableFilesReturnsEmptyWhenNoCliToolExists() Assert.Empty(candidates); } + [Fact] + public void FindCandidateExecutableFilesPrefersOffPathSystemWinGetOverBundledPinget() + { + const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe"; + const string packagedWinGet = + @"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe\winget.exe"; + + var candidates = WinGet.FindCandidateExecutableFiles( + static _ => [], + path => path == bundledPinget, + bundledPinget, + WinGetCliToolPreference.Default, + static () => [packagedWinGet] + ); + + Assert.Equal([packagedWinGet, bundledPinget], candidates); + } + + [Fact] + public void FindCandidateExecutableFilesDeduplicatesOffPathSystemWinGetAlreadyFoundOnPath() + { + const string systemWinGet = @"C:\WindowsApps\winget.exe"; + const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe"; + + var candidates = WinGet.FindCandidateExecutableFiles( + static executableName => executableName == "winget.exe" ? [systemWinGet] : [], + path => path == bundledPinget, + bundledPinget, + WinGetCliToolPreference.Default, + static () => [systemWinGet] + ); + + Assert.Equal([systemWinGet, bundledPinget], candidates); + } + + [Fact] + public void FindCandidateExecutableFilesIgnoresOffPathSystemWinGetInPingetMode() + { + const string bundledPinget = @"C:\Program Files\UniGetUI\pinget.exe"; + + var candidates = WinGet.FindCandidateExecutableFiles( + static _ => [], + path => path == bundledPinget, + bundledPinget, + WinGetCliToolPreference.BundledPinget, + static () => + throw new InvalidOperationException( + "System WinGet should not be queried in Pinget mode." + ) + ); + + Assert.Equal([bundledPinget], candidates); + } + + [Fact] + public void EnumerateOffPathExecutablesReturnsExecutionAliasAndAppInstallerLocations() + { + const string localAppData = @"C:\Users\test\AppData\Local"; + string alias = Path.Join(localAppData, "Microsoft", "WindowsApps", "winget.exe"); + const string packageRoot = + @"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe"; + string packagedWinGet = Path.Join(packageRoot, "winget.exe"); + + var executables = SystemWinGetLocator + .EnumerateOffPathExecutables( + path => path == alias || path == packagedWinGet, + localAppData, + () => [packageRoot] + ) + .ToArray(); + + Assert.Equal([alias, packagedWinGet], executables); + } + + [Fact] + public void EnumerateOffPathExecutablesSkipsDirectoriesWithoutWinGet() + { + const string packageRoot = + @"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe"; + string packagedWinGet = Path.Join(packageRoot, "winget.exe"); + + var executables = SystemWinGetLocator + .EnumerateOffPathExecutables( + path => path == packagedWinGet, + @"C:\Users\test\AppData\Local", + () => [@"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_0.0.0.0_x64__8wekyb3d8bbwe", packageRoot] + ) + .ToArray(); + + Assert.Equal([packagedWinGet], executables); + } + + [Fact] + public void EnumerateOffPathExecutablesIgnoresTheExecutionAliasWhenAppInstallerIsNotRegistered() + { + const string localAppData = @"C:\Users\test\AppData\Local"; + string alias = Path.Join(localAppData, "Microsoft", "WindowsApps", "winget.exe"); + + var executables = SystemWinGetLocator + .EnumerateOffPathExecutables(path => path == alias, localAppData, static () => []) + .ToArray(); + + Assert.Empty(executables); + } + + [Theory] + [InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", "1.29.290.0")] + [InlineData("Microsoft.DesktopAppInstaller_1.2_neutral__8wekyb3d8bbwe", "1.2")] + [InlineData("Microsoft.DesktopAppInstaller", "0.0")] + [InlineData("Microsoft.DesktopAppInstaller_notaversion_x64__8wekyb3d8bbwe", "0.0")] + public void ParsePackageVersionReadsTheVersionPieceOfThePackageFullName( + string packageFullName, + string expected + ) + { + Assert.Equal( + Version.Parse(expected), + SystemWinGetLocator.ParsePackageVersion(packageFullName) + ); + } + [Fact] public void PingetCliHelperDeserializesListResponsesWithGeneratedContext() { @@ -424,6 +545,10 @@ int expectedPreference [InlineData(@"C:\Program Files\UniGetUI\pinget.exe", 1)] [InlineData(@"C:\Tools\pinget.exe", 1)] [InlineData(@"C:\WindowsApps\winget.exe", 0)] + [InlineData( + @"C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe\winget.exe", + 0 + )] public void GetCliToolKindRecognizesPingetExecutableName( string executablePath, int expectedKind From 05d29819f2d38cce96840c21dc38cd2640a31eaf Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 16:15:11 -0400 Subject: [PATCH 2/3] Pin App Installer discovery to the Microsoft publisher id --- .../ClientHelpers/SystemWinGetLocator.cs | 20 +++++++++++++------ .../WinGetManagerTests.cs | 18 +++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs index 1227df4f04..5094605375 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs @@ -7,6 +7,7 @@ internal static class SystemWinGetLocator { private const string WinGetExecutableName = "winget.exe"; private const string AppInstallerPackageNamePrefix = "Microsoft.DesktopAppInstaller_"; + private const string AppInstallerPublisherIdSuffix = "_8wekyb3d8bbwe"; private const string AppxRepositoryKey = @"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; @@ -77,12 +78,7 @@ internal static IReadOnlyList ReadAppInstallerInstallDirectories() foreach (string packageFullName in root.GetSubKeyNames()) { - if ( - !packageFullName.StartsWith( - AppInstallerPackageNamePrefix, - StringComparison.OrdinalIgnoreCase - ) - ) + if (!IsAppInstallerPackageFullName(packageFullName)) { continue; } @@ -120,6 +116,18 @@ internal static IReadOnlyList ReadAppInstallerInstallDirectories() .ToArray(); } + internal static bool IsAppInstallerPackageFullName(string packageFullName) + { + return packageFullName.StartsWith( + AppInstallerPackageNamePrefix, + StringComparison.OrdinalIgnoreCase + ) + && packageFullName.EndsWith( + AppInstallerPublisherIdSuffix, + StringComparison.OrdinalIgnoreCase + ); + } + internal static Version ParsePackageVersion(string packageFullName) { string[] pieces = packageFullName.Split('_'); diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index d4cd521f87..1b4489038f 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -416,6 +416,24 @@ public void EnumerateOffPathExecutablesIgnoresTheExecutionAliasWhenAppInstallerI Assert.Empty(executables); } + [Theory] + [InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", true)] + [InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_neutral_split.scale-100_8wekyb3d8bbwe", true)] + [InlineData("Microsoft.DesktopAppInstaller_9.9.9.0_x64__1abcdefghijkl", false)] + [InlineData("Microsoft.DesktopAppInstallerExtra_1.0.0.0_x64__8wekyb3d8bbwe", false)] + [InlineData("Contoso.DesktopAppInstaller_1.0.0.0_x64__8wekyb3d8bbwe", false)] + [InlineData("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", false)] + public void IsAppInstallerPackageFullNameRequiresTheMicrosoftPublisherId( + string packageFullName, + bool expected + ) + { + Assert.Equal( + expected, + SystemWinGetLocator.IsAppInstallerPackageFullName(packageFullName) + ); + } + [Theory] [InlineData("Microsoft.DesktopAppInstaller_1.29.290.0_x64__8wekyb3d8bbwe", "1.29.290.0")] [InlineData("Microsoft.DesktopAppInstaller_1.2_neutral__8wekyb3d8bbwe", "1.2")] From 3a3ca9af36a4d6f810add5e3248667056f7f4462 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 8 Sep 2026 16:26:21 -0400 Subject: [PATCH 3/3] Match the App Installer identity by segment, not by string edges --- .../ClientHelpers/SystemWinGetLocator.cs | 16 ++++++---------- .../WinGetManagerTests.cs | 2 ++ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs index 5094605375..5b652924f7 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs @@ -6,8 +6,8 @@ namespace UniGetUI.PackageEngine.Managers.WingetManager; internal static class SystemWinGetLocator { private const string WinGetExecutableName = "winget.exe"; - private const string AppInstallerPackageNamePrefix = "Microsoft.DesktopAppInstaller_"; - private const string AppInstallerPublisherIdSuffix = "_8wekyb3d8bbwe"; + private const string AppInstallerPackageName = "Microsoft.DesktopAppInstaller"; + private const string AppInstallerPublisherId = "8wekyb3d8bbwe"; private const string AppxRepositoryKey = @"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; @@ -118,14 +118,10 @@ internal static IReadOnlyList ReadAppInstallerInstallDirectories() internal static bool IsAppInstallerPackageFullName(string packageFullName) { - return packageFullName.StartsWith( - AppInstallerPackageNamePrefix, - StringComparison.OrdinalIgnoreCase - ) - && packageFullName.EndsWith( - AppInstallerPublisherIdSuffix, - StringComparison.OrdinalIgnoreCase - ); + string[] pieces = packageFullName.Split('_'); + return pieces.Length >= 4 + && pieces[0].Equals(AppInstallerPackageName, StringComparison.OrdinalIgnoreCase) + && pieces[^1].Equals(AppInstallerPublisherId, StringComparison.OrdinalIgnoreCase); } internal static Version ParsePackageVersion(string packageFullName) diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index 1b4489038f..ecee1fec0b 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -423,6 +423,8 @@ public void EnumerateOffPathExecutablesIgnoresTheExecutionAliasWhenAppInstallerI [InlineData("Microsoft.DesktopAppInstallerExtra_1.0.0.0_x64__8wekyb3d8bbwe", false)] [InlineData("Contoso.DesktopAppInstaller_1.0.0.0_x64__8wekyb3d8bbwe", false)] [InlineData("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", false)] + [InlineData("Microsoft.DesktopAppInstaller_8wekyb3d8bbwe", false)] + [InlineData("Microsoft.DesktopAppInstaller", false)] public void IsAppInstallerPackageFullNameRequiresTheMicrosoftPublisherId( string packageFullName, bool expected