diff --git a/src/GameFinder.StoreHandlers.Steam/Models/RegistryEntry.cs b/src/GameFinder.StoreHandlers.Steam/Models/RegistryEntry.cs new file mode 100644 index 00000000..95f63d13 --- /dev/null +++ b/src/GameFinder.StoreHandlers.Steam/Models/RegistryEntry.cs @@ -0,0 +1,145 @@ +using System; +using System.Text; +using FluentResults; +using GameFinder.RegistryUtils; +using GameFinder.StoreHandlers.Steam.Models.ValueTypes; +using GameFinder.StoreHandlers.Steam.Services; +using JetBrains.Annotations; +using NexusMods.Paths; +using NexusMods.Paths.Extensions; + +namespace GameFinder.StoreHandlers.Steam.Models; + +/// +/// Represents a parsed registry entry. +/// +[PublicAPI] +public sealed record RegistryEntry +{ + /// + /// Gets the unique identifier of the app + /// that was parsed to produce this . + /// + public required AppId AppId { get; init; } + + #region Parsed Values + + /// + /// Gets the for the Uninstall registry subkey. + /// + public required IRegistryKey RegistryPath { get; init; } + + /// + /// Gets the path to the icon for this app. + /// + public AbsolutePath? DisplayIcon { get; init; } + + /// + /// Gets name of the app. + /// + public required string DisplayName { get; init; } + + /// + /// Gets the help URL (invariably https://help.steampowered.com/) + /// + public required string HelpLink { get; init; } + + /// + /// Gets the installation directory of the app. + /// + public required AbsolutePath? InstallLocation { get; init; } + + /// + /// Gets the publisher name + /// + public required string Publisher { get; init; } + + /// + /// Gets the uninstall executable + /// + /// "C:\Program Files\Steam\steam.exe" + public required AbsolutePath? UninstallExecutable { get; init; } + + /// + /// Gets the uninstall parameters (note the steam:// URL by itself without the executable should be sufficient) + /// + /// steam://uninstall/262060 + public required string UninstallParameters { get; init; } + + /// + /// Gets the info URL + /// + public required string URLInfoAbout { get; init; } + + #endregion + + #region Helpers + + private static readonly RelativePath CommonDirectoryName = "common".ToRelativePath(); + private static readonly RelativePath ShaderCacheDirectoryName = "shadercache".ToRelativePath(); + private static readonly RelativePath WorkshopDirectoryName = "workshop".ToRelativePath(); + private static readonly RelativePath CompatabilityDataDirectoryName = "compatdata".ToRelativePath(); + + /// + /// Parses the registry for again and returns a new + /// instance of . + /// + [Pure] + [System.Diagnostics.Contracts.Pure] + [MustUseReturnValue] + public Result Reload(IFileSystem fileSystem, IRegistry? registry) + { + return RegistryEntryParser.ParseRegistryEntry(AppId, fileSystem, registry); + } + + #endregion + + #region Overwrites + + /// + public bool Equals(RegistryEntry? other) + { + if (other is null) return false; + if (AppId != other.AppId) return false; + if (DisplayIcon != other.DisplayIcon) return false; + if (!string.Equals(DisplayName, other.DisplayName, StringComparison.Ordinal)) return false; + if (!string.Equals(HelpLink, other.HelpLink, StringComparison.Ordinal)) return false; + if (InstallLocation != other.InstallLocation) return false; + if (!string.Equals(Publisher, other.Publisher, StringComparison.Ordinal)) return false; + if (UninstallExecutable != other.UninstallExecutable) return false; + if (!string.Equals(UninstallParameters, other.UninstallParameters, StringComparison.Ordinal)) return false; + if (!string.Equals(URLInfoAbout, other.URLInfoAbout, StringComparison.Ordinal)) return false; + return true; + } + + /// + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(AppId); + hashCode.Add(DisplayIcon); + hashCode.Add(DisplayName); + hashCode.Add(HelpLink); + hashCode.Add(InstallLocation); + hashCode.Add(Publisher); + hashCode.Add(UninstallExecutable); + hashCode.Add(UninstallParameters); + hashCode.Add(URLInfoAbout); + return hashCode.ToHashCode(); + } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + + sb.Append("{ "); + sb.Append($"DisplayIcon = {DisplayIcon}, "); + sb.Append($"Uninstall = {UninstallExecutable} {UninstallParameters}"); + sb.Append(" }"); + + return sb.ToString(); + } + + #endregion +} diff --git a/src/GameFinder.StoreHandlers.Steam/Services/Parsers/RegistryEntryParser.cs b/src/GameFinder.StoreHandlers.Steam/Services/Parsers/RegistryEntryParser.cs new file mode 100644 index 00000000..01487560 --- /dev/null +++ b/src/GameFinder.StoreHandlers.Steam/Services/Parsers/RegistryEntryParser.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using FluentResults; +using GameFinder.RegistryUtils; +using GameFinder.StoreHandlers.Steam.Models; +using GameFinder.StoreHandlers.Steam.Models.ValueTypes; +using JetBrains.Annotations; +using NexusMods.Paths; + +namespace GameFinder.StoreHandlers.Steam.Services; + +/// +/// Parser for Steam Uninstall registry entries. +/// +/// +[PublicAPI] +public static class RegistryEntryParser +{ + internal const string UninstallRegKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall"; + + /// + /// Parses the registry entry for the given Steam app ID. + /// + public static Result ParseRegistryEntry(AppId appId, IFileSystem fileSystem, IRegistry? registry) + { + RegistryEntry regEntry; + + if (fileSystem is null) + { + return Result.Ok(); + return Result.Fail(new Error("Invalid filesystem parameter!")); + } + if (registry is null) + { + return Result.Ok(); + return Result.Fail(new Error("Invalid registry parameter!")); + } + + IRegistryKey? subKey = default; + + try + { + // Entries are usually in HKLM64, but occasionally HKLM32 (or both) + var localMachine64 = registry.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + var localMachine32 = registry.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var subKey64 = localMachine64.OpenSubKey(Path.Combine(UninstallRegKey, "Steam App " + appId)); + using var subKey32 = localMachine32.OpenSubKey(Path.Combine(UninstallRegKey, "Steam App " + appId)); + + subKey = subKey64; + if (subKey64 is null || string.IsNullOrEmpty(subKey64.GetString("InstallLocation")) && + subKey32 is not null && !string.IsNullOrEmpty(subKey32.GetString("InstallLocation"))) + { + subKey = subKey32; + } + + if (subKey is null) + { + return Result.Ok(); + return Result.Fail( + new Error("Invalid registry key!") + .WithMetadata("AppId", appId) + .WithMetadata("Key", subKey?.ToString()) + ); + } + + var strIcon = subKey.GetString("DisplayIcon"); + var strLoc = subKey.GetString("InstallLocation"); + var strUninst = subKey.GetString("UninstallString"); + var strUnExe = ""; + var strUnParam = ""; + if (strUninst is not null) + { + if (strUninst.StartsWith('"')) + { + strUnExe = strUninst[..strUninst.LastIndexOf('"')]; + strUnParam = strUninst[(strUninst.LastIndexOf('"') + 1)..]; + } + else if (strUninst.Contains(' ', StringComparison.Ordinal)) + { + strUnExe = strUninst[..strUninst.IndexOf(' ', StringComparison.Ordinal)]; + strUnParam = strUninst[(strUninst.IndexOf(' ', StringComparison.Ordinal) + 1)..]; + } + else + { + strUnExe = strUninst; + } + } + regEntry = new() + { + AppId = appId, + RegistryPath = subKey, + DisplayIcon = Path.IsPathRooted(strIcon) ? fileSystem.FromUnsanitizedFullPath(strIcon) : null, + DisplayName = subKey.GetString("DisplayName") ?? "", + HelpLink = subKey.GetString("HelpLink") ?? "", + InstallLocation = Path.IsPathRooted(strLoc) ? fileSystem.FromUnsanitizedFullPath(strLoc) : null, + Publisher = subKey.GetString("Publisher") ?? "", + UninstallExecutable = Path.IsPathRooted(strUnExe) ? fileSystem.FromUnsanitizedFullPath(strUnExe) : null, + UninstallParameters = strUnParam, + URLInfoAbout = subKey.GetString("URLInfoAbout") ?? "", + }; + + return regEntry; + } + catch (Exception ex) + { + return Result.Ok(); + return Result.Fail( + new ExceptionalError("Exception was thrown while parsing the registry!", ex) + .WithMetadata("Key", subKey?.ToString()) + ); + } + } +} diff --git a/src/GameFinder.StoreHandlers.Steam/SteamGame.cs b/src/GameFinder.StoreHandlers.Steam/SteamGame.cs index 21369554..3e6ec404 100644 --- a/src/GameFinder.StoreHandlers.Steam/SteamGame.cs +++ b/src/GameFinder.StoreHandlers.Steam/SteamGame.cs @@ -19,6 +19,11 @@ public sealed record SteamGame : IGame /// public required AppManifest AppManifest { get; init; } + /// + /// Gets the parsed of this game. + /// + public RegistryEntry? RegistryEntry { get; init; } + /// /// Gets the library folder that contains this game. /// diff --git a/src/GameFinder.StoreHandlers.Steam/SteamHandler.cs b/src/GameFinder.StoreHandlers.Steam/SteamHandler.cs index 757a0cd6..7fe30a2f 100644 --- a/src/GameFinder.StoreHandlers.Steam/SteamHandler.cs +++ b/src/GameFinder.StoreHandlers.Steam/SteamHandler.cs @@ -96,10 +96,19 @@ public override IEnumerable> FindAllGames() continue; } + var registryEntryResult = RegistryEntryParser.ParseRegistryEntry(appManifestResult.Value.AppId, _fileSystem, _registry); + /* + if (registryEntryResult.IsFailed) + { + yield return ConvertResultToErrorMessage(registryEntryResult); + } + */ + var steamGame = new SteamGame { SteamPath = steamPath, AppManifest = appManifestResult.Value, + RegistryEntry = registryEntryResult.Value ?? default, LibraryFolder = libraryFolder, };