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..5b652924f7 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/SystemWinGetLocator.cs @@ -0,0 +1,134 @@ +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 AppInstallerPackageName = "Microsoft.DesktopAppInstaller"; + private const string AppInstallerPublisherId = "8wekyb3d8bbwe"; + + 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 (!IsAppInstallerPackageFullName(packageFullName)) + { + 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 bool IsAppInstallerPackageFullName(string packageFullName) + { + 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) + { + 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..ecee1fec0b 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -311,6 +311,147 @@ 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", 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)] + [InlineData("Microsoft.DesktopAppInstaller_8wekyb3d8bbwe", false)] + [InlineData("Microsoft.DesktopAppInstaller", 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")] + [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 +565,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