diff --git a/CHANGELOG.md b/CHANGELOG.md index f683b2df..5122aab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,13 @@ Though upstream [GameFinder](https://github.com/erri120/GameFinder) adheres to [ - Add [PkgHandlers](https://github.com/Nutzzz/GameCollector/pull/40) to find installed software, and to download free games - Add [DataHandlers](https://github.com/Nutzzz/GameCollector/pull/13) to download metadata and images from online sources -## [Released](https://github.com/Nutzzz/GameCollector/releases) +## [4.2.4-2](https://github.com/Nutzzz/GameCollector/compare/v4.2.4.1...v4.2.4.2) - UNRELEASED + +### Added -## [4.3.2-1](https://github.com/Nutzzz/GameCollector/compare/v4.3.1.1...v4.3.2.1) - 2024-10-09 +- Add Winget package handler to detect both installed apps and show free game packages -- Upstream: Heroic: validate that file exists before parsing +## [Released](https://github.com/Nutzzz/GameCollector/releases) ## [4.3.1-1](https://github.com/Nutzzz/GameCollector/compare/v4.2.4.1...v4.3.1.1) - 2024-10-01 diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e136618..77dba488 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,16 +15,25 @@ - + - - + + + + + + + + + + + - - - + + + - + diff --git a/GameCollector.sln b/GameCollector.sln index b3c2f573..2719371b 100644 --- a/GameCollector.sln +++ b/GameCollector.sln @@ -136,6 +136,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCollector.EmuHandlers.D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCollector.EmuHandlers.MAME", "src\GameCollector.EmuHandlers.MAME\GameCollector.EmuHandlers.MAME.csproj", "{EA0A79A3-639B-4983-A28F-1D07EC1113C7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PkgHandlers", "PkgHandlers", "{55345886-D1F6-4733-9943-1283AF39CEA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCollector.PkgHandlers.Winget", "src\GameCollector.PkgHandlers.Winget\GameCollector.PkgHandlers.Winget.csproj", "{7C74842C-69FE-4908-BA71-210652A15744}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataHandlers", "DataHandlers", "{82816E34-196A-4304-8A28-BF22FA937DF0}" EndProject Global @@ -324,6 +328,10 @@ Global {EA0A79A3-639B-4983-A28F-1D07EC1113C7}.Debug|Any CPU.Build.0 = Debug|Any CPU {EA0A79A3-639B-4983-A28F-1D07EC1113C7}.Release|Any CPU.ActiveCfg = Release|Any CPU {EA0A79A3-639B-4983-A28F-1D07EC1113C7}.Release|Any CPU.Build.0 = Release|Any CPU + {7C74842C-69FE-4908-BA71-210652A15744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C74842C-69FE-4908-BA71-210652A15744}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C74842C-69FE-4908-BA71-210652A15744}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C74842C-69FE-4908-BA71-210652A15744}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -378,6 +386,8 @@ Global {1F5F230D-146C-4794-A2E6-F9C13279D721} = {EFB4E7DB-DE24-4BDE-B660-DAF1394ECC21} {6173D1F4-48C2-4B14-B907-C7A746CF081D} = {1F5F230D-146C-4794-A2E6-F9C13279D721} {EA0A79A3-639B-4983-A28F-1D07EC1113C7} = {1F5F230D-146C-4794-A2E6-F9C13279D721} + {55345886-D1F6-4733-9943-1283AF39CEA5} = {EFB4E7DB-DE24-4BDE-B660-DAF1394ECC21} + {7C74842C-69FE-4908-BA71-210652A15744} = {55345886-D1F6-4733-9943-1283AF39CEA5} {82816E34-196A-4304-8A28-BF22FA937DF0} = {EFB4E7DB-DE24-4BDE-B660-DAF1394ECC21} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/README.md b/README.md index 498272fb..8e8a131f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ .NET library for finding games. GameCollector expands on upstream GameFinder (which is primarily designed to support modding tools), by adding additional supported store launchers, emulators, and data sources, and includes additional information about each game (sufficient for a multi-store game launcher such as [GLC](https://github.com/Solaire/GLC)). This fork is indebted to erri120's hard work, but as [he is reticent](https://github.com/erri120/GameFinder/issues/49) to expand the scope of GameFinder, GameCollector continues with a different philosophy. -The following launchers and emulators are supported: +The following launchers, emulators, and package managers are supported: | handler | package | | -- | -- | @@ -36,6 +36,7 @@ The following launchers and emulators are supported: | [Steam](#steam) | [![Nuget](https://img.shields.io/nuget/v/GameCollector.StoreHandlers.Steam)](https://www.nuget.org/packages/GameCollector.StoreHandlers.Steam) [![Nuget](https://img.shields.io/nuget/v/GameFinder.StoreHandlers.Steam?color=red&label=upstream)](https://www.nuget.org/packages/GameFinder.StoreHandlers.Steam) | | Ubisoft Connect | [![Nuget](https://img.shields.io/nuget/v/GameCollector.StoreHandlers.Ubisoft)](https://www.nuget.org/packages/GameCollector.StoreHandlers.Ubisoft) | | Wargaming.net Game Center | [![Nuget](https://img.shields.io/nuget/v/GameCollector.StoreHandlers.WargamingNet)](https://www.nuget.org/packages/GameCollector.StoreHandlers.WargamingNet) | +| Windows Package Manager | [![Nuget](https://img.shields.io/nuget/v/GameCollector.PkgHandlers.Winget)](https://www.nuget.org/packages/GameCollector.PkgHandlers.Winget) | | [Xbox Game Pass](#xbox-game-pass) | [![Nuget](https://img.shields.io/nuget/v/GameCollector.StoreHandlers.Xbox)](https://www.nuget.org/packages/GameCollector.StoreHandlers.Xbox) [![Nuget](https://img.shields.io/nuget/v/GameFinder.StoreHandlers.Xbox?color=red&label=upstream)](https://www.nuget.org/packages/GameFinder.StoreHandlers.Xbox) | If you are interested in understanding _how_ GameCollector/GameFinder finds these games, check [the upstream wiki](https://github.com/erri120/GameFinder/wiki) for more information (descriptions of the added handlers should eventually be added to a wiki here). @@ -98,6 +99,12 @@ The TGame implementations of GameCollector's handlers inherit a generic GameData The Metadata dictionary may include (depending on available information): "ReleaseDate", "Description", "Developers", "Publishers", "Genres", "ImageUrl", etc. +### Supported Package Managers + +This is a new category of handler for GameCollector. It is Windows-only for now. + +- Winget + ### Supported Emulators This is a new category of handler for GameCollector. They are Windows-only for now. @@ -418,7 +425,7 @@ Self-contained deployments and executables can be [trimmed](https://learn.micros **NOT Trimmable**: -- `GameCollector.StoreHandlers.EADesktop`: This package references `System.Management`, which is **not trimmable** due to COM interop issues. See [dotnet/runtime#78038](https://github.com/dotnet/runtime/issues/78038), [dotnet/runtime#75176](https://github.com/dotnet/runtime/pull/75176) and [dotnet/runtime#61960](https://github.com/dotnet/runtime/issues/61960) for more details. +- `GameCollector.StoreHandlers.EADesktop` and `GameCollector.PkgHandlers.Winget`: These packages reference `System.Management`, which is **not trimmable** due to COM interop issues. See [dotnet/runtime#78038](https://github.com/dotnet/runtime/issues/78038), [dotnet/runtime#75176](https://github.com/dotnet/runtime/pull/75176) and [dotnet/runtime#61960](https://github.com/dotnet/runtime/issues/61960) for more details. I recommend looking at the [project file](./other/GameFinder.Example/GameFinder.Example.csproj) of the example project, if you run into warnings or errors with trimming. diff --git a/codecov.yml b/codecov.yml index 95a801a0..db5c0e8a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -132,6 +132,10 @@ component_management: name: Wargaming.net Game Center paths: - "src/GameCollector.StoreHandlers.WargamingNet/**" + - component_id: pkg_handler_winget + name: Windows Package Manager + paths: + - "src/GameCollector.PkgHandlers.Winget/**" - component_id: emu_handler_dolphin name: Dolphin Emulator paths: diff --git a/other/GameFinder.Example/GameFinder.Example.csproj b/other/GameFinder.Example/GameFinder.Example.csproj index cc84e17a..60e83a1a 100644 --- a/other/GameFinder.Example/GameFinder.Example.csproj +++ b/other/GameFinder.Example/GameFinder.Example.csproj @@ -52,6 +52,10 @@ + + + + diff --git a/other/GameFinder.Example/Options.cs b/other/GameFinder.Example/Options.cs index f51c897c..9c2adb3e 100644 --- a/other/GameFinder.Example/Options.cs +++ b/other/GameFinder.Example/Options.cs @@ -115,6 +115,9 @@ public class Options [Option("wargaming", HelpText = "Search for Wargaming.Net Game Center games", Hidden = true, Group = "Handlers")] public bool Wargaming { get; set; } = false; + [Option("winget", HelpText = "Search for Windows Package Manager packages", Hidden = true, Group = "Handlers")] + public bool Winget { get; set; } = false; + [Option("xbox", HelpText = "Search for Xbox Games Pass games", Hidden = true, Group = "Handlers")] public bool Xbox { get; set; } = false; diff --git a/other/GameFinder.Example/Program.cs b/other/GameFinder.Example/Program.cs index 642ec4b3..ef052ca0 100644 --- a/other/GameFinder.Example/Program.cs +++ b/other/GameFinder.Example/Program.cs @@ -37,6 +37,7 @@ using GameCollector.StoreHandlers.Rockstar; using GameCollector.StoreHandlers.Ubisoft; using GameCollector.StoreHandlers.WargamingNet; +using GameCollector.PkgHandlers.Winget; using GameCollector.EmuHandlers.Dolphin; using GameCollector.EmuHandlers.MAME; //using GameCollector.DataHandlers.TheGamesDb; @@ -115,13 +116,13 @@ private async static void Run(Options options, ILogger logger) var settings = new Settings(options.Installed, options.Parent || options.Base, options.Owned, options.Games); - if (options.All) // Enable all emulator and handlers + if (options.All) // Enable all handlers { options.Amazon = true; options.Arc = true; options.BattleNet = true; options.BigFish = true; - //options.Dolphin ??= ""; + //options.Dolphin ??= ""; // needs path options.EA = true; options.Epic = true; options.GameJolt = true; @@ -131,7 +132,7 @@ private async static void Run(Options options, ILogger logger) options.IG = true; options.Itch = true; options.Legacy = true; - //options.MAME ??= ""; + //options.MAME ??= ""; // needs path options.Oculus = true; options.Origin = true; options.Paradox = true; @@ -140,9 +141,10 @@ private async static void Run(Options options, ILogger logger) options.RobotCache = true; options.Rockstar = true; options.Steam = true; - //options.TheGamesDB = true; + //options.TheGamesDB = true; // WIP options.Ubisoft = true; options.Wargaming = true; + options.Winget = true; options.Xbox = true; } @@ -187,6 +189,8 @@ private async static void Run(Options options, ILogger logger) if (options.Ubisoft || options.Uplay) tasks.Add(Task.Run(() => RunUbisoftHandler(settings, windowsRegistry, realFileSystem), cancelToken)); if (options.Wargaming || options.WargamingNet) tasks.Add(Task.Run(() => RunWargamingNetHandler(settings, windowsRegistry, realFileSystem), cancelToken)); + if (options.Winget) tasks.Add(Task.Run(() => RunWingetHandler(settings, windowsRegistry, realFileSystem), cancelToken)); + if (options.Dolphin is not null) { tasks.Add(Task.Run(() => @@ -455,6 +459,12 @@ private static void RunWargamingNetHandler(Settings settings, IRegistry registry var handler = new WargamingNetHandler(registry, fileSystem); LogGamesAndErrors(handler.FindAllGames(settings), logger); } + private static void RunWingetHandler(Settings settings, IRegistry registry, IFileSystem fileSystem) + { + var logger = _provider.CreateLogger(nameof(WingetHandler)); + var handler = new WingetHandler(registry, fileSystem, logger); //, logger); + LogGamesAndErrors(handler.FindAllGames(settings), logger); + } private static void RunDolphinHandler(Settings settings, IRegistry registry, IFileSystem fileSystem, AbsolutePath path) { diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 15998280..a3209bf4 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ - 4.3.2.1 + 4.3.2.2 Nutzzz, erri120 docs\README.md diff --git a/src/GameCollector.PkgHandlers.Winget/GameCollector.PkgHandlers.Winget.csproj b/src/GameCollector.PkgHandlers.Winget/GameCollector.PkgHandlers.Winget.csproj new file mode 100644 index 00000000..fe57ff16 --- /dev/null +++ b/src/GameCollector.PkgHandlers.Winget/GameCollector.PkgHandlers.Winget.csproj @@ -0,0 +1,19 @@ + + + Library for finding installed apps and available game packages via Windows Package Manager. + winget windows-package-manager games + + + + + + + + + + + + + + + diff --git a/src/GameCollector.PkgHandlers.Winget/WingetGame.cs b/src/GameCollector.PkgHandlers.Winget/WingetGame.cs new file mode 100644 index 00000000..39ff5a8d --- /dev/null +++ b/src/GameCollector.PkgHandlers.Winget/WingetGame.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using GameFinder.Common; +using JetBrains.Annotations; +using NexusMods.Paths; + +namespace GameCollector.PkgHandlers.Winget; + +/// +/// Represents an installed app or available package via Windows Package Manager. +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +[PublicAPI] +public record WingetGame(WingetGameId Id, + string? Name, + AbsolutePath InstallDirectory, + AbsolutePath Launch = new(), + string? InstallerUrl = "", + AbsolutePath Uninstall = new(), + bool IsInstalled = true, + bool IsOwned = true, + DateTime? InstallDate = null, + string? Description = "", + string? Publisher = "", + List? PackageTags = default, + string? PublisherUrl = "", + string? SupportUrl = "", + string? Homepage = "", + string? Author = "", + string? PackageName = "", + string? Moniker = "", + string? Source = "", + string? LicenseType = "", + string? InstalledVersion = "", + string? DefaultVersion = "", + string? AgeRating = "") : + GameData(Handler: Handler.PkgHandler_Winget, + GameId: Id.ToString(), + GameName: Name ?? "", + GamePath: InstallDirectory, + Launch: Launch, + LaunchUrl: InstallerUrl ?? "", + Icon: Launch, + Uninstall: Uninstall, + IsInstalled: IsInstalled, + IsOwned: IsOwned, + InstallDate: InstallDate, + Metadata: new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["Description"] = new() { Description ?? "", }, + ["Developers"] = new() { Author ?? "", }, + ["Publishers"] = new() { Publisher ?? "", }, + ["Genres"] = PackageTags ?? new(), + ["WebInfo"] = new() { Homepage ?? PublisherUrl ?? "" }, + ["WebSupport"] = new() { SupportUrl ?? "" }, + ["PackageName"] = new() { PackageName ?? "" }, + ["Moniker"] = new() { Moniker ?? "", }, + ["Source"] = new() { Source ?? "", }, + ["License"] = new() { LicenseType ?? "", }, + ["InstalledVersion"] = new() { InstalledVersion ?? "", }, + ["DefaultVersion"] = new() { DefaultVersion ?? "", }, + ["AgeRating"] = new() { AgeRating ?? "", }, + }); diff --git a/src/GameCollector.PkgHandlers.Winget/WingetGameId.cs b/src/GameCollector.PkgHandlers.Winget/WingetGameId.cs new file mode 100644 index 00000000..315f8258 --- /dev/null +++ b/src/GameCollector.PkgHandlers.Winget/WingetGameId.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using TransparentValueObjects; + +namespace GameCollector.PkgHandlers.Winget; + +/// +/// Represents an id for installed apps and available packages via Windows Package Manager. +/// +[ValueObject] +public readonly partial struct WingetGameId : IAugmentWith +{ + /// + public static IEqualityComparer InnerValueDefaultEqualityComparer { get; } = StringComparer.OrdinalIgnoreCase; +} + +/// +[PublicAPI] +public class WingetGameIdComparer : IEqualityComparer +{ + private static WingetGameIdComparer? _default; + + /// + /// Default equality comparer that uses . + /// + public static WingetGameIdComparer Default => _default ??= new(); + + private readonly StringComparison _stringComparison; + + /// + /// Default constructor that uses . + /// + public WingetGameIdComparer() : this(StringComparison.OrdinalIgnoreCase) { } + + /// + /// Constructor. + /// + /// + public WingetGameIdComparer(StringComparison stringComparison) + { + _stringComparison = stringComparison; + } + + /// + public bool Equals(WingetGameId x, WingetGameId y) => string.Equals(x.Value, y.Value, _stringComparison); + + /// + public int GetHashCode(WingetGameId obj) => obj.Value.GetHashCode(_stringComparison); +} diff --git a/src/GameCollector.PkgHandlers.Winget/WingetHandler.cs b/src/GameCollector.PkgHandlers.Winget/WingetHandler.cs new file mode 100644 index 00000000..6325e05f --- /dev/null +++ b/src/GameCollector.PkgHandlers.Winget/WingetHandler.cs @@ -0,0 +1,894 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +//using System.Security.Principal; +using System.Text; +using GameFinder.Common; +using GameFinder.RegistryUtils; +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; +using NexusMods.Paths; +using OneOf; + +namespace GameCollector.PkgHandlers.Winget; + +/// +/// Handler for finding installed apps via Windows Package Manager. +/// +/// +/// Leverages winget, which inspects registry and WindowsApps (Microsoft Store) for installed programs. +/// +[PublicAPI] +public class WingetHandler : AHandler +{ + internal const string DefaultQuery = "game"; + internal const string UninstallRegKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall"; + + private readonly IRegistry _registry; + private readonly IFileSystem _fileSystem; + private ILogger? _logger; + + //private WindowsPackageManagerFactory? _winGetFactory = null; + + private ProcessStartInfo _startInfo = new() + { + UseShellExecute = false, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + }; + + /// + /// Constructor. + /// + /// + /// The implementation of to use. For a shared instance + /// use on Windows. On Linux use null. + /// For tests either use , a custom implementation or just a mock + /// of the interface. + /// + /// + /// The implementation of to use. For a shared instance use + /// . For tests either use , + /// a custom implementation or just a mock of the interface. + /// + public WingetHandler(IRegistry registry, IFileSystem fileSystem, ILogger? logger = null) + { + _registry = registry; + _fileSystem = fileSystem; + _logger = logger; + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041, 0)) + { + // If the user is an administrator, use the elevated factory. Otherwhise COM will crash + /* + if (new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator)) + _winGetFactory = new WindowsPackageManagerElevatedFactory(); + else + _winGetFactory = new WindowsPackageManagerStandardFactory(); + */ + } + } + + /// + public override IEqualityComparer? IdEqualityComparer => WingetGameIdComparer.Default; + + /// + public override Func IdSelector => game => game.Id; + + /// + public override AbsolutePath FindClient() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var wingetExe = _fileSystem.FromUnsanitizedFullPath(Path.Combine(localAppData, "Microsoft", "WindowsApps", "winget.exe")); + if (Path.IsPathRooted(localAppData) && _fileSystem.FileExists(wingetExe)) + return wingetExe; + + return default; + } + + /// + public override IEnumerable> FindAllGames(Settings? settings = null) + { + return FindAllGames(settings?.InstalledOnly ?? false, settings?.OwnedOnly ?? false, settings?.GamesOnly ?? false); + } + + /// + /// Finds all apps supported by this package handler. The return type + /// will always be a non-null game or a non-null error message. + /// + /// + /// + /// + /// + /// Get additional information about winget/msstore packages + /// + public IEnumerable> FindAllGames( + bool installedOnly = false, + bool ownedOnly = false, + bool gamesOnly = false, + string? query = DefaultQuery, + bool expandPackage = false) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041, 0)) + { + yield return new ErrorMessage("Only supported on Windows 10.0.19041.0 or later."); + yield break; + } + /* + if (_winGetFactory is null) + { + yield return new ErrorMessage("Could not access Windows Package Manager."); + yield break; + } + */ + + Dictionary> installed = new(); + + // Create Package Manager and get available catalogs + //var manager = _winGetFactory.CreatePackageManager(); + //var availableCatalogs = manager.GetPackageCatalogs(); + + /* + foreach (var catalog in availableCatalogs.ToArray()) + { + //var filterList = _winGetFactory.CreateFindPackagesOptions(); + // Create a filter to search for packages with query ["game" default] + + // Add the query to the filter + var tagFilter = _winGetFactory.CreatePackageMatchFilter(); + tagFilter.Field = PackageMatchField.Tag; + tagFilter.Value = query; + filterList.Filters.Add(tagFilter); + + // Find the packages with the filters + //var searchResults = await catalog.Connect().PackageCatalog.FindPackagesAsync(filterList); + var searchResults = catalog.Connect().PackageCatalog.FindPackages(filterList); + foreach (var match in searchResults.Matches.ToArray()) + { + var pkg = match.CatalogPackage; + var id = WingetGameId.From(pkg.Id); + + installed.Add(id, new WingetGame( + Id: id, + Name: pkg.Name, + InstallDirectory: default, + //PkgTags: pkg.Tags, + //Source: pkg.Source, + InstalledVersion: pkg.InstalledVersion.DisplayName, + DefaultVersion: pkg.DefaultInstallVersion.DisplayName + )); + } + } + */ + + // Get all installed items + //var installedCatalogs = manager.GetLocalPackageCatalog; + + if (!gamesOnly) + { + //installed = GetInstalled(); + installed = GetInstalledParse(expandPackage); + foreach (var item in installed) + { + if (item.Value.IsT0) + { + yield return new WingetGame( + Id: item.Key, + Name: item.Value.AsT0.Name, + InstallDirectory: default, + IsInstalled: true, + IsOwned: true, + //PkgTags: item.CatalogPackage.Tags, + //Source: item.CatalogPackage.Source, + InstalledVersion: item.Value.AsT0.InstalledVersion, + DefaultVersion: item.Value.AsT0.DefaultVersion + ); + } + else yield return item.Value.AsT1; + } + } + + //var freeGames = SearchFreeGames(); + + if (!installedOnly && !string.IsNullOrEmpty(query)) + { + foreach (var game in SearchFreeGamesParse(query, expandPackage)) + { + if (!installed.ContainsKey(game.Key)) + { + if (game.Value.IsT0) + { + yield return new WingetGame( + Id: game.Key, + Name: game.Value.AsT0.Name, + InstallDirectory: default, + IsInstalled: false, + IsOwned: false, + DefaultVersion: game.Value.AsT0.DefaultVersion + ); + } + else yield return game.Value.AsT1; + } + } + } + } + + private Dictionary> GetInstalled() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041, 0)) + { + _logger?.LogDebug("***Only supported on Windows 10.0.19041.0 or later."); + return new() { [WingetGameId.From("")] = new ErrorMessage("Only supported on Windows 10.0.19041.0 or later.") }; + } + /* + if (_winGetFactory is null) + { + _logger?.LogDebug("***Could not access Windows Package Manager."); + return new() { [WingetGameId.From("")] = new ErrorMessage("Could not access Windows Package Manager.") }; + } + */ + + // Fetching installed packages + //var winGetManager = _winGetFactory.CreatePackageManager(); + + // CHANGE THIS INDEX TO CHANGE THE SOURCE + var selectedIndex = 0; // does this refer to winget vs. msstore? + + /* + PackageCatalogReference installedSearchCatalogRef; + if (selectedIndex < 0) + { + installedSearchCatalogRef = winGetManager.GetLocalPackageCatalog(LocalPackageCatalog.InstalledPackages); + } + else + { + var selectedRemoteCatalogRef = winGetManager.GetPackageCatalogs().ToArray()[selectedIndex]; + + //_logger?.LogDebug($"Searching on package catalog {selectedRemoteCatalogRef.Info.Name} "); + + var createCompositePackageCatalogOptions = _winGetFactory.CreateCreateCompositePackageCatalogOptions(); + createCompositePackageCatalogOptions.Catalogs.Add(selectedRemoteCatalogRef); + createCompositePackageCatalogOptions.CompositeSearchBehavior = CompositeSearchBehavior.LocalCatalogs; + installedSearchCatalogRef = winGetManager.CreateCompositePackageCatalog(createCompositePackageCatalogOptions); + } + + var connectResult = installedSearchCatalogRef.Connect(); + if (connectResult.Status != ConnectResultStatus.Ok) + { + yield return new ErrorMessage("Failed to connect to local catalog."); + yield break; + } + + var findPackagesOptions = _winGetFactory.CreateFindPackagesOptions(); + var filter = _winGetFactory.CreatePackageMatchFilter(); + filter.Field = PackageMatchField.Id; + filter.Option = PackageFieldMatchOption.ContainsCaseInsensitive; + filter.Value = ""; + findPackagesOptions.Filters.Add(filter); + + var taskResult = connectResult.PackageCatalog.FindPackages(findPackagesOptions); + */ + + // Begin enumeration + /* + foreach (var match in taskResult.Matches.ToArray()) + { + var pkg = match.CatalogPackage; + //_logger?.LogDebug($"Package {pkg.Name} is available Online: " + pkg.DefaultInstallVersion.PackageCatalog.Info.Name); + yield return new WingetGame( + Id: WingetGameId.From(pkg.Id), + Name: pkg.Name, + InstallDirectory: default, + IsInstalled: pkg.DefaultInstallVersion != null, + IsOwned: pkg.DefaultInstallVersion != null, + //PackageTags: pkg.Tags???, + Source: pkg.DefaultInstallVersion?.Channel, // is this winget vs. msstore? + PackageName: pkg.DefaultInstallVersion?.PackageCatalog.Info.Name, + //ProductCodes: pkg.DefaultInstallVersion?.ProductCodes.ToList(), + Publisher: pkg.InstalledVersion.Publisher, + InstalledVersion: pkg.InstalledVersion.DisplayName, + DefaultVersion: pkg.DefaultInstallVersion?.DisplayName); + } + */ + + // End enumeration + return new(); + } + + private Dictionary> GetInstalled(bool? expandPackage) + { + Dictionary> installed = new(); + return installed; + } + + private Dictionary> GetInstalledParse(bool expandPackage) + { + //************************************* + Console.OutputEncoding = Encoding.UTF8; + //************************************* + + Dictionary> installed = new(); + + var wingetExe = FindClient(); + if (wingetExe == default) + { + _logger?.LogDebug("***Winget not installed"); + return new() { [WingetGameId.From("")] = new ErrorMessage("Winget not installed") }; + } + + using var process = new Process(); + process.StartInfo = _startInfo; + process.StartInfo.FileName = wingetExe.GetFullPath(); + process.StartInfo.Arguments = "list --nowarn --disable-interactivity"; + process.StartInfo.StandardOutputEncoding = Encoding.UTF8; + process.StartInfo.StandardErrorEncoding = Encoding.UTF8; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + if (string.IsNullOrEmpty(output)) + { + _logger?.LogDebug("***No output from winget"); + return new() { [WingetGameId.From("")] = new ErrorMessage("No output from winget") }; + } + + List colPos = new(); + var i = 0; + foreach (var line in output.Split('\n')) + { + if (i == 0) + { + if (!line.Contains("Name ", StringComparison.Ordinal)) + continue; + + var headLine = line[line.IndexOf("Name ", StringComparison.Ordinal)..]; + foreach (var col in headLine.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var c = headLine.IndexOf(col, StringComparison.Ordinal); + if (c > -1) + colPos.Add(c); + } + if (colPos.Count < 5) + break; + } + else if (i > 1 && !string.IsNullOrWhiteSpace(line)) // skip separator line + { + string name = "", strId = "", version = "", available = ""; + string? source = null; + WingetGameId id = default; + // TODO: This doesn't work if there are surrogate pairs in the string + if (line.Length > colPos[1]) + name = line[colPos[0]..colPos[1]].Trim(); + if (line.Length > colPos[2]) + { + strId = line[colPos[1]..colPos[2]].Trim(); + id = WingetGameId.From(strId); + } + if (line.Length > colPos[3]) + version = line[colPos[2]..colPos[3]].Trim(); + if (line.Length > colPos[4]) + { + available = line[colPos[3]..colPos[4]].Trim(); + source = line[colPos[4]..].Trim(); + } + + if (string.IsNullOrWhiteSpace(source)) // non-package install + { + if (!expandPackage) + { + if (installed.TryAdd(id, new WingetGame( + Id: id, + Name: name, + InstallDirectory: default, + IsInstalled: true, + IsOwned: true, + InstalledVersion: version))) + { + _logger?.LogDebug(" " + name); + } + i++; + continue; + } + + if (name.EndsWith('…') || strId.EndsWith('…') || version.EndsWith('…')) + { + (name, strId, version) = Relist(name, strId, version, source); + strId = strId.TrimEnd('…'); + id = WingetGameId.From(strId); + } + + if (!string.IsNullOrEmpty(strId) && !strId.EndsWith('…')) + { + var game = ParseRegistry(strId); + if (game.IsT0) + { + if (installed.TryAdd(id, new WingetGame( + Id: id, + Name: name, + InstallDirectory: game.AsT0.InstallDirectory, + Launch: game.AsT0.Launch, + Uninstall: game.AsT0.Uninstall, + IsInstalled: true, + IsOwned: true, + InstallDate: game.AsT0.InstallDate, + Publisher: game.AsT0.Publisher, + SupportUrl: game.AsT0.SupportUrl, + Homepage: game.AsT0.Homepage, + InstalledVersion: version))) + { + _logger?.LogDebug("@ " + name); + } + i++; + continue; + } + + if (installed.TryAdd(id, game.AsT1)) + { + _logger?.LogDebug("***" + game.AsT1.Message); + } + i++; + } + + if (installed.TryAdd(id, new WingetGame( + Id: id, + Name: name, + InstallDirectory: default, + IsInstalled: true, + IsOwned: true, + InstalledVersion: version))) + { + _logger?.LogDebug("? " + name); + } + i++; + continue; + } + + //else [package install] + + if (!expandPackage) + { + if (name.EndsWith('…') || strId.EndsWith('…') || version.EndsWith('…')) + { + (name, strId, version) = Relist(name, strId, version, source); + strId = strId.TrimEnd('…'); + id = WingetGameId.From(strId); + } + + if (installed.TryAdd(id, new WingetGame( + Id: id, + Name: name, + InstallDirectory: default, + IsInstalled: true, + IsOwned: true, + Source: source, + InstalledVersion: version, + DefaultVersion: available))) + { + _logger?.LogDebug(" " + name); + } + i++; + continue; + } + + if (strId.EndsWith('…')) + { + (name, strId, version) = Relist(name, strId, version, source); + strId = strId.TrimEnd('…'); + id = WingetGameId.From(strId); + } + if (installed.TryAdd(id, GetPackageInfo(colPos, line, search: false))) + { + _logger?.LogDebug(" " + name); + } + } + + i++; + } + _logger?.LogDebug("GetInstalledParse(): " + i + " apps"); + + return installed; + } + + private (string, string, string) Relist(string name, string id, string version, string? source) + { + var wingetExe = FindClient(); + + if (string.IsNullOrWhiteSpace(source)) + source = "winget"; + + using var process = new Process(); + process.StartInfo = _startInfo; + process.StartInfo.FileName = wingetExe.GetFullPath(); + //process.StartInfo.Arguments = $"list --name \"{name.TrimEnd('…')}\" --source {source} --nowarn --disable-interactivity"; + process.StartInfo.Arguments = $"list --id \"{id.TrimEnd('…')}\" --source {source} --nowarn --disable-interactivity"; + process.Start(); + var listOutput = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + List listCols = new(); + var i = 0; + foreach (var listLine in listOutput.Split('\n')) + { + if (i == 0) + { + if (!listLine.Contains("Name", StringComparison.Ordinal)) + continue; + + var headLine = listLine[listLine.IndexOf("Name ", StringComparison.Ordinal)..]; + foreach (var col in headLine.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var c = headLine.IndexOf(col, StringComparison.Ordinal); + if (c > -1) + listCols.Add(c); + } + if (listCols.Count < 3) + break; + } + else if (i > 1 && !string.IsNullOrWhiteSpace(listLine)) // skip separator line + { + // TODO: This doesn't work if there are surrogate pairs in the string + if (listLine.Length > listCols[1]) + name = listLine[listCols[0]..listCols[1]].Trim(); + if (listLine.Length > listCols[2]) + { + id = listLine[listCols[1]..listCols[2]].Trim(); + version = listLine[listCols[2]..].Trim(); + } + } + + i++; + } + + return (name, id, version); + } + + private Dictionary> SearchFreeGamesParse(string query = DefaultQuery, bool expandPackage = false) + { + Dictionary> freeGames = new(); + + var wingetExe = FindClient(); + + using var process = new Process(); + process.StartInfo = _startInfo; + process.StartInfo.FileName = wingetExe.GetFullPath(); + process.StartInfo.Arguments = $"search --tag {query} --source winget --nowarn --disable-interactivity"; + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + if (string.IsNullOrEmpty(output)) + { + _logger?.LogDebug("***No output from winget"); + return new() { [WingetGameId.From("")] = new ErrorMessage("No output from winget") }; + } + + List colPos = new(); + var i = 0; + foreach (var line in output.Split('\n')) + { + if (i == 0) + { + if (!line.Contains("Name ", StringComparison.Ordinal)) + continue; + + var headLine = line[line.IndexOf("Name ", StringComparison.Ordinal)..]; + foreach (var col in headLine.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var c = headLine.IndexOf(col, StringComparison.Ordinal); + if (c > -1) + colPos.Add(c); + } + if (colPos.Count < 5) + break; + } + else if (i > 1 && !string.IsNullOrWhiteSpace(line)) // skip separator line + { + string name = "", strId = "", available = "", match = ""; + string? source = null; + WingetGameId id = default; + // TODO: This doesn't work if there are surrogate pairs in the string + if (line.Length > colPos[1]) + name = line[colPos[0]..colPos[1]].Trim(); + if (line.Length > colPos[2]) + { + strId = line[colPos[1]..colPos[2]].Trim(); + id = WingetGameId.From(strId); + } + if (line.Length > colPos[3]) + available = line[colPos[2]..colPos[3]].Trim(); + if (line.Length > colPos[4]) + { + match = line[colPos[3]..colPos[4]].Trim(); + source = line[colPos[4]..].Trim(); + } + + if (!expandPackage) + { + if (freeGames.TryAdd(id, new WingetGame( + Id: id, + Name: name, + InstallDirectory: default, + IsInstalled: false, + IsOwned: false, + DefaultVersion: available))) + { + _logger?.LogDebug("* " + name); + i++; + } + continue; + } + + if (freeGames.TryAdd(id, GetPackageInfo(colPos, line, search: true))) + { + _logger?.LogDebug("*@" + name); + } + } + + i++; + } + _logger?.LogDebug("SearchFreeGamesParse(): " + i + " games"); + + return freeGames; + } + + private OneOf ParseRegistry(string id) + { + // id syntax = "ARP\[Machine|User]\[X64|X86]\ + + string? regKeyName; // = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\"; + IRegistryKey baseKey; // = _registry.OpenBaseKey(RegistryHive.LocalMachine); + if (id.StartsWith(@"ARP\Machine\X64\", StringComparison.Ordinal)) + { + regKeyName = UninstallRegKey + id[@"ARP\Machine\X64\".Length..]; + baseKey = _registry.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + } + else if (id.StartsWith(@"ARP\Machine\X86\", StringComparison.Ordinal)) + { + regKeyName = UninstallRegKey + id[@"ARP\Machine\X86\".Length..]; + baseKey = _registry.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + } + else if (id.StartsWith(@"ARP\User\X64\", StringComparison.Ordinal)) + { + regKeyName = UninstallRegKey + id[@"ARP\User\X64\".Length..]; + baseKey = _registry.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64); + } + else if (id.StartsWith(@"ARP\User\X86\", StringComparison.Ordinal)) + { + regKeyName = UninstallRegKey + id[@"ARP\User\X86\".Length..]; + baseKey = _registry.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32); + } + else + return new ErrorMessage("Did not find expected \"ARP\\[Machine|User]\\[X64|X86]\\\" registry key prefix in id " + id); + + try + { + using var regKey = baseKey.OpenSubKey(regKeyName); + if (regKey is null) + { + return new ErrorMessage($"Unable to open {regKeyName}"); + } + + if (!regKey.TryGetString("DisplayName", out var name)) + name = ""; + + if (!regKey.TryGetString("HelpLink", out var help)) + help = ""; + + DateTime installDate = default; + if (regKey.TryGetString("InstallDate", out var date)) + DateTime.TryParseExact(date, "yyyyMMdd", new CultureInfo("en-US"), DateTimeStyles.None, out installDate); + + if (!regKey.TryGetString("InstallLocation", out var path)) + path = ""; + + if (!regKey.TryGetString("DisplayIcon", out var launch)) + launch = ""; + + if (!regKey.TryGetString("Publisher", out var pub)) + pub = ""; + + if (!regKey.TryGetString("UninstallString", out var uninst)) + uninst = ""; + + if (!regKey.TryGetString("URLInfoAbout", out var url)) + url = ""; + + return new WingetGame( + Id: WingetGameId.From(id), + Name: name, + InstallDirectory: Path.IsPathRooted(path) ? _fileSystem.FromUnsanitizedFullPath(path) : new(), + InstallDate: installDate == default ? null : installDate, + Launch: Path.IsPathRooted(launch) ? _fileSystem.FromUnsanitizedFullPath(launch) : new(), + Uninstall: Path.IsPathRooted(uninst) ? _fileSystem.FromUnsanitizedFullPath(uninst) : new(), + Publisher: pub, + SupportUrl: help, + Homepage: url + ); + } + catch (Exception e) + { + return new ErrorMessage(e, $"Exception while parsing registry key {regKeyName}"); + } + } + + private OneOf GetPackageInfo(List colPos, string listInfo, bool search = false) + { + //list header = "Name,Id,Version,Available,Source" + //search header = "Name,Id,Version,Match,Source" + + var wingetExe = FindClient(); + + var isDescription = false; + //var isReleaseNotes = false; + var isTags = false; + + var name = listInfo[colPos[0]..colPos[1]].Trim(); + var strId = listInfo[colPos[1]..colPos[2]].Trim(); + var id = WingetGameId.From(strId); + var version = ""; + string? available; + //string? match; + if (search) + { + available = listInfo[colPos[2]..colPos[3]].Trim(); + //match = listInfo[colPos[3]..colPos[4]].Trim(); + } + else + { + version = listInfo[colPos[2]..colPos[3]].Trim(); + available = listInfo[colPos[3]..colPos[4]].Trim(); + } + var source = listInfo[colPos[4]..].Trim(); + var pkgName = ""; + var publisher = ""; + //var publisherUrl = ""; + var publisherSupportUrl = ""; + var author = ""; + var moniker = ""; + var description = ""; + var homepage = ""; + var license = ""; + //var licenseUrl = ""; + //var privacyUrl = ""; + //var copyright = ""; + //var releaseNotes = ""; + //var releaseNotesUrl = ""; + //var purchaseUrl = ""; + //var documentationTutorials = ""; + //var documentationManual = ""; + List tags = new(); + //var agreementsCategory = ""; + //var agreementsPricing = ""; + //string agreementsFreeTrial = ""; + var agreementsAgeRatings = ""; + //string agreementsTermsOfTransaction = ""; + //string agreementsSeizureWarning = ""; + //string agreementsStoreLicenseTerms = ""; + //var installerType = ""; + //var installerLocale = ""; + var installerUrl = ""; + //var installerSha256 = ""; + //var installerStoreProductId = ""; + //var installerReleaseDate = ""; + //var installerOfflineDistributionSupported = false; + + using var process = new Process(); + process.StartInfo = _startInfo; + process.StartInfo.FileName = wingetExe.GetFullPath(); + process.StartInfo.Arguments = $"show {strId} --nowarn --disable-interactivity"; + process.Start(); + var pkgOutput = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + var i = 0; + foreach (var pkgLine in pkgOutput.Split('\n')) + { + if (isDescription) + { + if (pkgLine.StartsWith(' ')) + { + description += pkgLine.TrimStart(); + continue; + } + isDescription = false; + } + /* + if (isReleaseNotes) + { + if (pkgLine.StartsWith(' ')) + { + releaseNotes += pkgLine.TrimStart(); + continue; + } + isReleaseNotes = false; + } + */ + if (isTags) + { + if (pkgLine.StartsWith(' ')) + { + tags.Add(pkgLine.TrimStart()); + continue; + } + isTags = false; + } + + if (i == 0) + { + if (pkgLine.Contains("Found ", StringComparison.Ordinal)) + { + var headLine = pkgLine[pkgLine.IndexOf("Found ", StringComparison.Ordinal)..]; + if (headLine.LastIndexOf('[') > 7) + { + strId = headLine[(headLine.LastIndexOf('[') + 1)..^1]; + id = WingetGameId.From(strId); + pkgName = headLine["Found ".Length..headLine.LastIndexOf('[')]; + } + else + pkgName = headLine["Found ".Length..]; + } + else + continue; + } + else if (pkgLine.StartsWith("Version: ", StringComparison.Ordinal)) + version = pkgLine["Version: ".Length..]; + else if (pkgLine.StartsWith("Publisher: ", StringComparison.Ordinal)) + publisher = pkgLine["Publisher: ".Length..]; + else if (pkgLine.StartsWith("Publisher Support Url: ", StringComparison.Ordinal)) + publisherSupportUrl = pkgLine["Publisher Support Url: ".Length..]; + else if (pkgLine.StartsWith("Moniker: ", StringComparison.Ordinal)) + moniker = pkgLine["Moniker: ".Length..]; + else if (pkgLine.StartsWith("Description:", StringComparison.Ordinal)) + { + description = pkgLine["Description:".Length..].TrimStart(); + isDescription = true; + } + else if (pkgLine.StartsWith("Homepage: ", StringComparison.Ordinal)) + homepage = pkgLine["Homepage: ".Length..]; + else if (pkgLine.StartsWith("License: ", StringComparison.Ordinal)) + license = pkgLine["License: ".Length..]; + //else if (pkgLine.StartsWith("Release Notes:", StringComparison.Ordinal)) + //{ + // releaseNotes = pkgLine["Release Notes:".Length..].TrimStart(); + // isReleaseNotes = true; + //} + else if (pkgLine.StartsWith(" Age Ratings: ", StringComparison.Ordinal)) + agreementsAgeRatings = pkgLine[" Age Ratings: ".Length..]; + else if (pkgLine.StartsWith("Tags:", StringComparison.Ordinal)) + isTags = true; + else if (pkgLine.StartsWith(" Installer Url: ", StringComparison.Ordinal)) + installerUrl = pkgLine[" Installer Url: ".Length..]; + + i++; + } + + _logger?.LogDebug("> " + name); + return new WingetGame( + Id: id, + Name: name, + InstallDirectory: default, + InstallerUrl: installerUrl, + IsInstalled: true, + IsOwned: true, + Description: description, + Publisher: publisher, + Author: author, + PackageTags: tags, + Homepage: homepage, + SupportUrl: publisherSupportUrl, + PackageName: pkgName, + Moniker: moniker, + Source: source, + LicenseType: license, + InstalledVersion: version, + DefaultVersion: available, + AgeRating: agreementsAgeRatings); + } +} diff --git a/src/GameFinder.Common/Handler.cs b/src/GameFinder.Common/Handler.cs index d7953b7c..e89b1e06 100644 --- a/src/GameFinder.Common/Handler.cs +++ b/src/GameFinder.Common/Handler.cs @@ -144,6 +144,11 @@ public enum Handler [Description("GameCollector.EmuHandlers.MAME")] EmuHandler_MAME, /// + /// Windows Package Manager package handler + /// + [Description("GameCollector.PkgHandlers.Winget")] + PkgHandler_Winget, + /// /// TheGamesDb.net data handler /// [Description("GameCollector.DataHandlers.TheGamesDb")] diff --git a/src/GameFinder/GameFinder.csproj b/src/GameFinder/GameFinder.csproj index 2f9962dd..0490b24c 100644 --- a/src/GameFinder/GameFinder.csproj +++ b/src/GameFinder/GameFinder.csproj @@ -46,6 +46,7 @@ +