diff --git a/GenHub/GenHub.Core/Constants/ActionSetConstants.cs b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs new file mode 100644 index 000000000..aca93073d --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs @@ -0,0 +1,396 @@ +namespace GenHub.Core.Constants; + +using System.Collections.Generic; +using System.IO; + +/// +/// Centralized constants for ActionSet fixes, registry keys, and file operations. +/// +public static class ActionSetConstants +{ + // RegistryKeys moved to GenHub.Core.Constants.RegistryConstants.cs + + /// + /// File names and content. + /// + public static class FileNames + { + /// + /// Gets the desktop.ini file name used for folder customization. + /// + public const string DesktopIni = "desktop.ini"; + + /// + /// Gets the Generals.exe file name. + /// + public const string GeneralsExe = "generals.exe"; + + /// + /// Gets the Game.dat file name. + /// + public const string GameDat = "Game.dat"; + + /// + /// Gets the game.exe file name, often used for Zero Hour. + /// + public const string GameExe = "game.exe"; // Often used for ZH + + /// + /// Gets the DXSETUP.exe file name used for DirectX runtime installer. + /// + public const string DxSetupExe = "DXSETUP.exe"; + } + + /// + /// Initialization file sections and keys. + /// + public static class IniFiles + { + // Sections + + /// + /// Gets the [.ShellClassInfo] section name for desktop.ini files. + /// + public const string ShellClassInfoSection = "[.ShellClassInfo]"; + + /// + /// Gets the TheSuperHackers section name for Options.ini files. + /// + public const string TheSuperHackersSection = "TheSuperHackers"; + + // Keys + + /// + /// Gets the ThisPCPolicy key name used to disable OneDrive sync. + /// + public const string ThisPCPolicyKey = "ThisPCPolicy"; + + /// + /// Gets the ThisPCPolicy value to disable OneDrive cloud sync. + /// + public const string ThisPCPolicyValue = "DisableCloudSync"; + + /// + /// Gets the ConfirmFileOp key name used in desktop.ini files. + /// + public const string ConfirmFileOpKey = "ConfirmFileOp"; + + // TheSuperHackers keys + + /// + /// Gets the ScrollEdgeZone key name for edge scrolling settings. + /// + public const string ScrollEdgeZoneKey = "ScrollEdgeZone"; + + /// + /// Gets the ScrollEdgeSpeed key name for edge scrolling settings. + /// + public const string ScrollEdgeSpeedKey = "ScrollEdgeSpeed"; + + /// + /// Gets the ScrollEdgeAcceleration key name for edge scrolling settings. + /// + public const string ScrollEdgeAccelerationKey = "ScrollEdgeAcceleration"; + + /// + /// Gets the ScrollFactor key name for edge scrolling settings. + /// + public const string ScrollFactorKey = "ScrollFactor"; + } + + /// + /// ActionSet category constants. + /// + public static class Categories + { + /// + /// Gets the All category filter option. + /// + public const string All = "All"; + + /// + /// Gets the Core & Stability category. + /// + public const string CoreAndStability = "Core & Stability"; + + /// + /// Gets the Compatibility category. + /// + public const string Compatibility = "Compatibility"; + + /// + /// Gets the Multiplayer category. + /// + public const string Multiplayer = "Multiplayer"; + + /// + /// Gets the Quality of Life category. + /// + public const string QualityOfLife = "Quality of Life"; + } + + /// + /// Firewall rule names and protocols. + /// + public static class FirewallRules + { + /// + /// Gets the prefix used for firewall rule names for GenPatcher compatibility. + /// + public const string Prefix = "GP"; // Compatibility with GenPatcher + + /// + /// Gets the firewall rule name for UDP port 16000. + /// + public const string PortRuleUdp16000 = "GP Open UDP Port 16000"; + + /// + /// Gets the firewall rule name for UDP port 16001. + /// + public const string PortRuleUdp16001 = "GP Open UDP Port 16001"; + + /// + /// Gets the firewall rule name for TCP port 16001. + /// + public const string PortRuleTcp16001 = "GP Open TCP Port 16001"; + + /// + /// Gets the firewall rule name for Generals.exe. + /// + public const string GeneralsRule = "GP Command & Conquer Generals"; + + /// + /// Gets the firewall rule name for Generals Game.dat. + /// + public const string GeneralsGameDatRule = "GP Command & Conquer Generals Game.dat"; + + /// + /// Gets the firewall rule name for Zero Hour. + /// + public const string ZeroHourRule = "GP Command & Conquer Generals Zero Hour"; + + /// + /// Gets the firewall rule name for Zero Hour Game.dat. + /// + public const string ZeroHourGameDatRule = "GP Command & Conquer Generals Zero Hour Game.dat"; + + /// + /// Gets the UDP protocol string. + /// + public const string ProtocolUdp = "UDP"; + + /// + /// Gets the TCP protocol string. + /// + public const string ProtocolTcp = "TCP"; + } + + /// + /// Constants for Malwarebytes detection and paths. + /// + public static class Malwarebytes + { + /// + /// Gets the registry uninstall key path for detecting Malwarebytes. + /// + public const string RegistryUninstallKey = RegistryConstants.UninstallKeyPath; + + /// + /// Gets the DisplayName value name in the registry. + /// + public const string DisplayNameValue = RegistryConstants.DisplayNameValueName; + + /// + /// Gets the string to check for in DisplayName to identify Malwarebytes. + /// + public const string NameContains = "Malwarebytes"; + + /// + /// Gets the array of executable paths for Malwarebytes applications. + /// + public static readonly IReadOnlyList ExecutablePaths = + [ + Path.Combine("Malwarebytes", "Anti-Malware", "mbam.exe"), + Path.Combine("Malwarebytes", "Anti-Malware", "mbamtray.exe") + ]; + } + + /// + /// File and directory paths used by ActionSets. + /// + public static class Paths + { + /// + /// Gets the directory name for sub-action set markers. + /// + public const string SubActionSetMarkers = "sub_markers"; + + /// + /// Gets the marker file name for remove read-only fix. + /// + public const string ReadOnlyFixMarker = ".gp_ro_fix"; + } + + /// + /// Default serial keys used for fallback generation. + /// + public static class Serials + { + /// + /// Default placeholder serial for Generals EA App installations. + /// + public const string DefaultEAAppGeneralsSerial = "GENS1234567890ABCDEF"; + + /// + /// Default placeholder serial for Zero Hour EA App installations. + /// + public const string DefaultEAAppZeroHourSerial = "ZH1234567890ABCDEFGH"; + } + + /// + /// UI status badge colors. + /// + public static class StatusColors + { + /// Hex color for applied state. + public const string Applied = "#28a745"; + + /// Hex color for unapplied state. + public const string Unapplied = "#ffc107"; + + /// Hex color for not applicable state. + public const string NotApplicable = "#6c757d"; + + /// Hex color for checking state. + public const string Checking = "#17a2b8"; + + /// Hex color for error state. + public const string Error = "#dc3545"; + + /// Hex background color for applied state badge. + public const string AppliedBackground = "#2228A745"; + + /// Hex background color for unapplied state badge. + public const string UnappliedBackground = "#22FFC107"; + + /// Hex background color for not applicable state badge. + public const string NotApplicableBackground = "#156c757d"; + + /// Hex border color for applied state badge. + public const string AppliedBorder = "#4428A745"; + + /// Hex border color for unapplied state badge. + public const string UnappliedBorder = "#44FFC107"; + + /// Hex border color for not applicable state badge. + public const string NotApplicableBorder = "#256c757d"; + } + + /// + /// Validation constants for file operations. + /// + public static class Validation + { + /// + /// Minimum file size for VCRedist installers (1000 KB). + /// + public const long VCRedistMinSize = 1000 * 1024; + + /// + /// Minimum file size for DirectX web setup installer (200 KB). + /// + public const long DirectXWebSetupMinSize = 200 * 1024; + + /// + /// Minimum file size for DirectX runtime ZIP package (1 MB). + /// + public const long DirectXPackageMinSize = 1024 * 1024; + + /// + /// Minimum file size for patch archives and installers (1 MB). + /// + public const long PatchMinSize = 1024 * 1024; + + /// + /// Minimum file size for GenTool archive (200 KB). + /// + public const long GenToolMinSize = 200 * 1024; + + /// + /// Minimum file size for addon packages like custom windows and high-definition icons (1 KB). + /// + public const long MinimumAddonPackageSizeBytes = 1024; + + /// + /// Maximum file size for addon packages like custom windows and high-definition icons (200 MB). + /// + public const long MaximumAddonPackageSizeBytes = 200 * 1024 * 1024; + } + + /// + /// Security constants for digital signature and Authenticode publisher validation. + /// + public static class Security + { + /// + /// Gets the expected Microsoft Corporation Authenticode publisher string. + /// + public const string MicrosoftPublisher = "Microsoft Corporation"; + + /// + /// Gets the expected Electronic Arts Authenticode publisher string. + /// + public const string ElectronicArtsPublisher = "Electronic Arts"; + + /// + /// Gets the pinned SHA-256 hash for the Generals 1.08 patch archive. + /// + public const string Generals108PatchSha256 = "265ff414850ef92e94828508f849a363c7fbe994d6994c6405e9eeaaa0f6b5c5"; + + /// + /// Gets the pinned SHA-256 hash for the DirectX runtime ZIP archive. + /// + public const string DirectXRuntimeZipSha256 = "6fcc7cd1be32422d07f022424412d6fe3141c6ba3845b855cb6f1b18f9c3a0a7"; + + /// + /// Gets the pinned SHA-256 hash for the GenTool archive package. + /// + public const string GenToolArchiveSha256 = "62bb0380ae14c570b6fad92b31784bec188dc22ac5ac9e11d3c524e08fa434e4"; + + /// + /// Gets the pinned SHA-256 hash for the GenTool d3d8.dll binary. + /// + public const string GenToolD3D8DllSha256 = "be5276180d04b3de9abd20aeaf2c1f65a2b65c800233ce49d5e77f1ab42441f7"; + + /// + /// Gets the pinned SHA-256 hash for the Expanded LAN Lobby / Custom Windows cbbs.dat package. + /// + public const string ExpandedLANLobbySha256 = "41f4c65c89bfae958d593a841b7f77aa6737cd12f810f5a3903a0a4cd6f7482d"; + + /// + /// Gets the pinned SHA-256 hash for the High-Definition Icons icon.dat package. + /// + public const string HDIconsSha256 = "68aedc84b0c4291dee7bdd079c551273e33cee4026ecc482ab48850cf99f7baa"; + } + + /// + /// Constants for confirmation and notification dialogs. + /// + public static class Dialogs + { + /// + /// Gets the title for the Apply All recommended fixes confirmation dialog. + /// + public const string ApplyAllConfirmationTitle = "Apply All Recommended Fixes"; + + /// + /// Gets the confirmation button text for the Apply All dialog. + /// + public const string ApplyAllConfirmButtonText = "Apply Fixes"; + + /// + /// Gets the cancel button text for the Apply All dialog. + /// + public const string ApplyAllCancelButtonText = "Cancel"; + } +} diff --git a/GenHub/GenHub.Core/Constants/ExternalUrls.cs b/GenHub/GenHub.Core/Constants/ExternalUrls.cs new file mode 100644 index 000000000..abe8965c2 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ExternalUrls.cs @@ -0,0 +1,108 @@ +namespace GenHub.Core.Constants; + +using System.Diagnostics.CodeAnalysis; + +/// +/// Constants for external URLs used for downloading dependencies or tools. +/// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized static URL constants repository")] +public static class ExternalUrls +{ + /// + /// Download URL for Visual C++ 2010 Redistributable Package (x86). + /// Required for Generals and Zero Hour to run. + /// + public const string VCRedist2010DownloadUrl = "https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x86.exe"; + + /// + /// Gets the primary download URL for DirectX runtime (Microsoft Official). + /// + public const string DirectXRuntimeDownloadUrlPrimary = "https://download.microsoft.com/download/1/7/1/1718CCC4-6315-4D8E-9543-8E28A4E18C4C/dxwebsetup.exe"; + + /// + /// Gets the secondary download URL for DirectX runtime (Gentool). + /// + public const string DirectXRuntimeDownloadUrlMirror1 = "https://gentool.net/program_data/genpatcher/drtx.dat"; + + /// + /// Download URL for Generals 1.08 official patch. + /// + public const string Generals108PatchUrl = "https://gentool.net/program_data/genpatcher/10gn.dat"; + + /// + /// Gets the primary download URL for Zero Hour 1.04 patch (CNCNZ). + /// + public const string ZeroHour104PatchUrlPrimary = "https://http.cncnz.com/patches/GeneralsZH-104-english.exe"; + + /// + /// Gets the secondary download URL for Zero Hour 1.04 patch (Gentool). + /// + public const string ZeroHour104PatchUrlMirror1 = "https://gentool.net/program_data/genpatcher/10zh.dat"; + + /// + /// Gets the primary download URL for GenTool (Gentool). + /// + public const string GenToolDownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/gent.dat"; + + /// + /// Gets the secondary download URL for GenTool (Legi.cc). + /// + public const string GenToolDownloadUrlMirror1 = "https://legi.cc/gp2/f/gent.dat"; + + /// + /// Gets the primary download URL for High-Definition Icons (Legi.cc). + /// + public const string HDIconsDownloadUrlPrimary = "https://legi.cc/gp2/f/icon.dat"; + + /// + /// Gets the primary download URL for Expanded LAN Lobby Menu & Custom Windows (Gentool). + /// + public const string ExpandedLANLobbyDownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/cbbs.dat"; + + /// + /// Gets the secondary download URL for Expanded LAN Lobby Menu & Custom Windows (Legi.cc). + /// + public const string ExpandedLANLobbyDownloadUrlMirror1 = "https://legi.cc/gp2/f/cbbs.dat"; + + /// + /// Gets the primary download URL for Visual C++ 2005 Redistributable (Gentool). + /// + public const string VCRedist2005DownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/vcredist_x86-2005.exe"; + + /// + /// Gets the secondary download URL for Visual C++ 2005 Redistributable (Legi.cc). + /// + public const string VCRedist2005DownloadUrlMirror1 = "https://legi.cc/gp2/f/vc05.dat"; + + /// + /// Gets the primary download URL for Visual C++ 2008 Redistributable (Gentool). + /// + public const string VCRedist2008DownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/vcredist_x86-2008.exe"; + + /// + /// Gets the secondary download URL for Visual C++ 2008 Redistributable (Legi.cc). + /// + public const string VCRedist2008DownloadUrlMirror1 = "https://legi.cc/gp2/f/vc08.dat"; + + // Legacy support + + /// + /// Legacy download URL for DirectX runtime. + /// + public const string DirectXRuntimeDownloadUrl = DirectXRuntimeDownloadUrlPrimary; + + /// + /// Legacy download URL for Zero Hour 1.04 patch. + /// + public const string ZeroHour104PatchUrl = ZeroHour104PatchUrlPrimary; + + /// + /// Download URL for Intel Graphics Drivers. + /// + public const string IntelDriverDownloadUrl = "https://www.intel.com/content/www/us/en/download-center/home"; + + /// + /// Support URL for Windows Media Feature Pack. + /// + public const string WindowsMediaFeaturePackSupportUrl = "https://support.microsoft.com/en-us/windows/media-feature-pack-for-windows-10-11-n-and-kn-editions-8007a829-873b-e0cf-dd4e-9d2fa7848fbb"; +} diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index 6937d2e2c..d9b304752 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -147,6 +147,18 @@ public static class GameClientConstants /// public const string ZeroHourShortName = "Zero Hour"; + /// BrowserEngine.dll filename. + public const string BrowserEngineDll = "BrowserEngine.dll"; + + /// BrowserEngine.dll backup filename. + public const string BrowserEngineDllBak = "BrowserEngine.dll.bak"; + + /// dbghelp.dll filename. + public const string DbgHelpDll = "dbghelp.dll"; + + /// dbghelp.dll backup filename. + public const string DbgHelpDllBak = "dbghelp.dll.bak"; + /// /// DLLs required for standard game installations. /// diff --git a/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs index 0aa9fd77a..371f311eb 100644 --- a/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs @@ -159,10 +159,31 @@ public static class FolderNames /// public const string Replays = "Replays"; + /// + /// Folder name for Command and Conquer Generals German settings. + /// + public const string GeneralsGerman = "Command and Conquer Generals Daten"; + + /// + /// Folder name for Command and Conquer Generals Zero Hour German settings. + /// + public const string ZeroHourGerman = "Command and Conquer Generals Zero Hour Daten"; + /// /// Subfolder name for screenshots within the game data directory. /// public const string Screenshots = "Screenshots"; + + /// + /// All known user data folder names for Generals and Zero Hour (including localized variants). + /// + public static readonly IReadOnlyList AllUserDataFolderNames = + [ + Generals, + ZeroHour, + GeneralsGerman, + ZeroHourGerman, + ]; } /// @@ -203,4 +224,209 @@ public static class ResolutionPresets "7680x4320", // 8K ]; } + + /// + /// Optimal settings for game performance and compatibility. + /// + public static class OptimalSettings + { + // Video + + /// + /// Gets the optimal anti-aliasing value (1 = 2x). + /// + public const int AntiAliasing = 1; + + /// + /// Gets the optimal texture reduction value (0 = no reduction). + /// + public const int TextureReduction = 0; + + /// + /// Gets a value indicating whether extra animations are enabled. + /// + public const bool ExtraAnimations = true; + + /// + /// Gets the optimal gamma correction value (50 = neutral). + /// + public const int OptimalGamma = 50; + + /// + /// Gets a value indicating whether shadow decals are enabled. + /// + public const bool UseShadowDecals = true; + + /// + /// Gets a value indicating whether shadow volumes are enabled. + /// + public const bool UseShadowVolumes = false; + + /// + /// Gets a value indicating whether windowed mode is enabled. + /// + public const bool Windowed = false; + + /// + /// Gets the optimal default resolution width (1920). + /// + public const int DefaultResolutionWidth = 1920; + + /// + /// Gets the optimal default resolution height (1080). + /// + public const int DefaultResolutionHeight = 1080; + + // Audio + + /// + /// Gets the optimal volume level (70), common for SFX, Music, and Voice. + /// + public const int VolumeLevel = 70; // Common for SFX, Music, Voice + + /// + /// Gets a value indicating whether audio is enabled. + /// + public const bool AudioEnabled = true; + + /// + /// Gets the optimal number of sounds (16). + /// + public const int NumSounds = 16; + + // Network + + /// + /// Gets the optimal GameSpy IP address (0.0.0.0 for local). + /// + public const string GameSpyIPAddress = "0.0.0.0"; + + // TheSuperHackers + + /// + /// Gets the building occlusion setting ("yes"). + /// + public const string BuildingOcclusion = "yes"; + + /// + /// Gets the campaign difficulty setting ("0"). + /// + public const string CampaignDifficulty = "0"; + + /// + /// Gets the dynamic LOD setting ("no"). + /// + public const string DynamicLOD = "no"; + + /// + /// Gets the firewall port override setting ("16001"). + /// + public const string FirewallPortOverride = "16001"; + + /// + /// Gets the heat effects setting ("no"). + /// + public const string HeatEffects = "no"; + + /// + /// Gets the ideal static game LOD setting ("High"). + /// + public const string IdealStaticGameLOD = "High"; + + /// + /// Gets the language filter setting ("false"). + /// + public const string LanguageFilter = "false"; + + /// + /// Gets the max particle count setting ("1000"). + /// + public const string MaxParticleCount = "1000"; + + /// + /// Gets the retaliation setting ("yes"). + /// + public const string Retaliation = "yes"; + + /// + /// Gets the scroll factor setting ("60"). + /// + public const string ScrollFactor = "60"; + + /// + /// Gets the send delay setting ("no"). + /// + public const string SendDelay = "no"; + + /// + /// Gets the show soft water edge setting ("yes"). + /// + public const string ShowSoftWaterEdge = "yes"; + + /// + /// Gets the show trees setting ("yes"). + /// + public const string ShowTrees = "yes"; + + /// + /// Gets the static game LOD setting ("Custom"). + /// + public const string StaticGameLOD = "Custom"; + + /// + /// Gets the use alternate mouse setting ("no"). + /// + public const string UseAlternateMouse = "no"; + + /// + /// Gets the use cloud map setting ("yes"). + /// + public const string UseCloudMap = "yes"; + + /// + /// Gets the use double click attack move setting ("no"). + /// + public const string UseDoubleClickAttackMove = "no"; + + /// + /// Gets the use light map setting ("yes"). + /// + public const string UseLightMap = "yes"; + + /// + /// Gets the scroll edge zone setting ("0"). + /// + public const string ScrollEdgeZone = "0"; + + /// + /// Gets the scroll edge speed setting ("1.0"). + /// + public const string ScrollEdgeSpeed = "1.0"; + + /// + /// Gets the scroll edge acceleration setting ("0.0"). + /// + public const string ScrollEdgeAcceleration = "0.0"; + } + + /// + /// Problematic resolutions that crash or distort Generals/Zero Hour. + /// + public static class ProblematicResolutions + { + /// + /// Gets the list of problematic resolution pairs (width, height). + /// + public static readonly IReadOnlyList<(int Width, int Height)> KnownBadResolutions = + [ + (0, 0), + (320, 240), + (400, 300), + (512, 384), + (640, 480), + (1366, 768), + (1360, 768), + (1280, 768), + ]; + } } diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs index d4d7187ff..c768db359 100644 --- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs +++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs @@ -34,6 +34,16 @@ public static class ProcessConstants /// public const int ExitCodeAccessDenied = 5; + /// + /// Exit code indicating success with reboot required (Windows Installer standard). + /// + public const int ExitCodeRebootRequired = 3010; + + /// + /// PowerShell executable name. + /// + public const string PowerShellExecutable = "powershell.exe"; + // Windows API constants /// diff --git a/GenHub/GenHub.Core/Constants/RegistryConstants.cs b/GenHub/GenHub.Core/Constants/RegistryConstants.cs new file mode 100644 index 000000000..f051a40a0 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/RegistryConstants.cs @@ -0,0 +1,177 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for Windows Registry keys and values. +/// +public static class RegistryConstants +{ + // ===== EA App / Origin Keys ===== + + /// Registry key path for Generals command and conquer. + public const string EAAppGeneralsKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Generals"; + + /// Registry key path for Zero Hour. + public const string EAAppZeroHourKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour"; + + /// Registry key path for Generals Ergc (Serial). + public const string EAAppGeneralsErgcKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Generals\ergc"; + + /// Registry key path for Zero Hour Ergc (Serial). + public const string EAAppZeroHourErgcKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour\ergc"; + + // ===== VCRedist Keys ===== + + /// Squished (compressed) GUID for Visual C++ 2005 Redistributable x86. + public const string VCRedist2005SquishedGuid = "b25099274a207264182f8181add555d0"; + + /// Registry key for VCRedist 2005 in Installer UserData Products. + public const string VCRedist2005InstallerProductsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\" + VCRedist2005SquishedGuid; + + /// Registry key for VCRedist 2005 in WOW6432Node Installer UserData Products. + public const string VCRedist2005InstallerProductsKeyWow64 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\" + VCRedist2005SquishedGuid; + + /// Registry key for VCRedist 2005 in Classes Installer Products. + public const string VCRedist2005ClassesKey = @"SOFTWARE\Classes\Installer\Products\" + VCRedist2005SquishedGuid; + + /// Registry key for VCRedist 2010 x86 (32-bit). + public const string VCRedist2010x86Key = @"SOFTWARE\Microsoft\VisualStudio\10.0\VC\VCRedist\x86"; + + /// Registry key for VCRedist 2010 x86 (64-bit environment / WOW6432Node). + public const string VCRedist2010x86KeyWow64 = @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\10.0\VC\VCRedist\x86"; + + // ===== Value Names ===== + + /// Registry value name for 'Install Path'. + public const string InstallPathValueName = "Install Path"; + + /// Registry value name for 'Version'. + public const string VersionValueName = "Version"; + + /// Registry value name for 'Installed'. + public const string InstalledValueName = "Installed"; + + // ===== Registry Versions (DWORD) ===== + + /// Registry version for Generals 1.08 (0x10008). + public const int GeneralsVersionDWord = 0x10008; + + /// Registry version for Zero Hour 1.04 (0x10004). + public const int ZeroHourVersionDWord = 0x10004; + + // ===== Windows System Keys ===== + + /// Registry key path for Windows Compatibility Flags (AppCompatLayers). + public const string AppCompatLayersKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"; + + // ===== The First Decade Keys ===== + + /// Registry key path for The First Decade. + public const string TheFirstDecadeKeyPath = @"SOFTWARE\EA Games\Command & Conquer The First Decade"; + + /// The First Decade registry version data string ("1.03"). + public const string TfdVersionData = "1.03"; + + /// Registry value data for TFD Version (alias for backward compatibility). + public const string TfdVersionValue = TfdVersionData; + + // ===== C&C Online (Revora) Keys ===== + + /// Registry key path for C&C Online (Root). + public const string CncOnlineKeyPath = @"SOFTWARE\Revora\CNCOnline"; + + /// Registry key path for C&C Online Generals. + public const string CncOnlineGeneralsKeyPath = @"SOFTWARE\Revora\CNCOnline\Generals"; + + /// Registry key path for C&C Online Zero Hour. + public const string CncOnlineZeroHourKeyPath = @"SOFTWARE\Revora\CNCOnline\ZeroHour"; + + /// C&C Online Version. + public const string CncOnlineVersion = "1.0"; + + /// C&C Online Generals Version. + public const string CncOnlineGeneralsVersion = "1.08"; + + /// C&C Online Zero Hour Version. + public const string CncOnlineZeroHourVersion = "1.04"; + + // ===== Malwarebytes Keys ===== + + /// Registry key path for Uninstall (used for detection). + public const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + /// Registry value name for DisplayName. + public const string DisplayNameValueName = "DisplayName"; + + // ===== Intel Graphics Keys ===== + + /// Registry key path for Intel Graphics Class. + public const string IntelGraphicsClassKeyPath = @"SYSTEM\CurrentControlSet\Control\Class\{4D36E968-E325-11CE-BFC1-08002BE10318}"; + + /// Registry key path for Intel MEWiz. + public const string IntelMEWizKeyPath = @"SOFTWARE\Intel\MEWiz1.0"; + + // ===== Windows Media Feature Pack ===== + + /// Registry key path for Windows Media Player Feature. + public const string WindowsMediaPlayerFeatureKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\WindowsFeatures\WindowsMediaPlayer"; + + // ===== Origin Keys ===== + + /// Registry key path for Origin. + public const string OriginKeyPath = @"SOFTWARE\Origin"; + + /// Registry key path for Origin in WOW6432Node. + public const string OriginKeyPathWow64 = @"SOFTWARE\WOW6432Node\Origin"; + + /// Registry value name for Origin Client Path. + public const string OriginClientPathValue = "ClientPath"; + + // ===== WOW64 Uninstall Key ===== + + /// Registry key path for 32-bit Uninstall under WOW64. + public const string UninstallKeyPathWow64 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"; + + // ===== TCP/IP IPv6 Parameters ===== + + /// Registry key path for TCPIP6 Parameters. + public const string Tcpip6ParametersKeyPath = @"SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters"; + + /// Registry value name for DisabledComponents. + public const string DisabledComponentsValueName = "DisabledComponents"; + + /// Registry DWORD value for Prefer IPv4 over IPv6 (0x20 = 32). + public const int PreferIPv4DisabledComponentsValue = 32; + + // ===== Fonts ===== + + /// Registry key path for Windows Fonts. + public const string FontsKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"; + + /// Registry font value name for Arial TrueType font. + public const string ArialFontValueName = "Arial (TrueType)"; + + // ===== Component Based Servicing (CBS) ===== + + /// Registry key path for CBS Packages. + public const string CbsPackagesKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\Packages"; + + /// Registry value name for CBS InstallState. + public const string InstallStateValueName = "InstallState"; + + /// CBS InstallState: Installed (7). + public const int CbsInstallStateInstalled = 7; + + /// CBS InstallState: Staged (112). + public const int CbsInstallStateStaged = 112; + + /// CBS InstallState: Superseded (128). + public const int CbsInstallStateSuperseded = 128; + + // ===== WMI Constants ===== + + /// WMI Scope for CIMV2. + public const string WmiScopeCimV2 = @"root\CIMV2"; + + /// WMI Query for Video Controller. + public const string WmiQueryVideoController = "SELECT * FROM Win32_VideoController"; +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs new file mode 100644 index 000000000..1dc61950f --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs @@ -0,0 +1,288 @@ +namespace GenHub.Core.Features.ActionSets; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +/// +/// Implementation of the ActionSet orchestrator. +/// +/// The initial collection of action sets. +/// The collection of action set providers. +/// The logger instance. +public class ActionSetOrchestrator( + IEnumerable actionSets, + IEnumerable providers, + ILogger logger) : IActionSetOrchestrator +{ + private enum ExecutionOutcome + { + Success, + Skipped, + FailedNonCritical, + FailedCritical, + } + + private readonly IReadOnlyList _actionSets = InitializeActionSets(actionSets, providers, logger); + + /// + public IReadOnlyList GetAllActionSets() => _actionSets; + + /// + public async Task> GetApplicableCoreFixesAsync(GameInstallation installation, CancellationToken ct = default) + { + var applicable = new List(); + foreach (var actionSet in _actionSets.Where(x => x.IsCoreFix)) + { + ct.ThrowIfCancellationRequested(); + try + { + if (await actionSet.IsApplicableAsync(installation, ct)) + { + applicable.Add(actionSet); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking applicability for {Title}", actionSet.Title); + } + } + + return applicable; + } + + /// + public async Task> ApplyActionSetsAsync( + GameInstallation installation, + IEnumerable actionSets, + CancellationToken ct = default) + { + var stopwatch = Stopwatch.StartNew(); + int successCount = 0; + var errors = new List(); + var actionSetsList = actionSets.ToList(); + int totalCount = actionSetsList.Count; + + logger.LogInformation("Starting to apply {TotalCount} action sets to {Installation}", totalCount, installation.InstallationPath); + + for (int i = 0; i < actionSetsList.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var outcome = await ProcessActionSetAsync( + actionSetsList[i], + installation, + i + 1, + totalCount, + errors, + ct); + + if (outcome == ExecutionOutcome.Success) + { + successCount++; + } + else if (outcome == ExecutionOutcome.FailedCritical) + { + return OperationResult.CreateFailure(errors, successCount, stopwatch.Elapsed); + } + } + + stopwatch.Stop(); + logger.LogInformation( + "Finished applying action sets. Success: {SuccessCount}/{TotalCount}, Errors: {ErrorCount}", + successCount, + totalCount, + errors.Count); + + if (errors.Count > 0) + { + return OperationResult.CreateFailure(errors, successCount, stopwatch.Elapsed); + } + + return OperationResult.CreateSuccess(successCount, stopwatch.Elapsed); + } + + private static IReadOnlyList InitializeActionSets( + IEnumerable actionSets, + IEnumerable providers, + ILogger logger) + { + var setMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (actionSets != null) + { + RegisterDirectActionSets(actionSets, setMap, logger); + } + + if (providers != null) + { + RegisterProviderActionSets(providers, setMap, logger); + } + + return setMap.Values.ToList(); + } + + private static void RegisterDirectActionSets( + IEnumerable actionSets, + Dictionary setMap, + ILogger logger) + { + foreach (var set in actionSets) + { + if (set == null) + { + continue; + } + + if (!setMap.TryAdd(set.Id, set)) + { + logger.LogWarning("Duplicate action set ID {Id} ignored from direct registration", set.Id); + } + } + } + + private static void RegisterProviderActionSets( + IEnumerable providers, + Dictionary setMap, + ILogger logger) + { + foreach (var provider in providers) + { + try + { + foreach (var set in provider.GetActionSets()) + { + if (set == null) + { + continue; + } + + if (!setMap.TryAdd(set.Id, set)) + { + logger.LogWarning("Duplicate action set ID {Id} ignored from provider {Provider}", set.Id, provider.GetType().Name); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load action sets from provider {Provider}", provider.GetType().Name); + } + } + } + + private async Task ProcessActionSetAsync( + IActionSet actionSet, + GameInstallation installation, + int index, + int totalCount, + List errors, + CancellationToken ct) + { + var eligible = await CheckEligibilityAsync(actionSet, installation, errors, ct); + if (eligible != ExecutionOutcome.Success) + { + return eligible; + } + + return await ApplySingleActionSetAsync(actionSet, installation, index, totalCount, errors, ct); + } + + private async Task CheckEligibilityAsync( + IActionSet actionSet, + GameInstallation installation, + List errors, + CancellationToken ct) + { + try + { + if (!await actionSet.IsApplicableAsync(installation, ct)) + { + logger.LogDebug("Skipping {Title} - not applicable", actionSet.Title); + return ExecutionOutcome.Skipped; + } + + if (await actionSet.IsAppliedAsync(installation, ct)) + { + logger.LogDebug("Skipping {Title} - already applied", actionSet.Title); + return ExecutionOutcome.Skipped; + } + + return ExecutionOutcome.Success; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error checking eligibility for {Title}", actionSet.Title); + errors.Add($"Error checking {actionSet.Title}: {ex.Message}"); + if (actionSet.IsCrucialFix) + { + logger.LogError("Critical fix {Title} eligibility check failed. Aborting sequence.", actionSet.Title); + errors.Add($"Critical fix '{actionSet.Title}' eligibility check failed. Remaining fixes were not applied."); + return ExecutionOutcome.FailedCritical; + } + + return ExecutionOutcome.FailedNonCritical; + } + } + + private async Task ApplySingleActionSetAsync( + IActionSet actionSet, + GameInstallation installation, + int index, + int totalCount, + List errors, + CancellationToken ct) + { + try + { + logger.LogInformation("Applying action set {Index}/{Total}: {Title}", index, totalCount, actionSet.Title); + var result = await actionSet.ApplyAsync(installation, ct); + + if (result.Success) + { + logger.LogInformation("Successfully applied {Title}", actionSet.Title); + return ExecutionOutcome.Success; + } + + var errorMessage = result.ErrorMessage ?? "Unknown error"; + logger.LogWarning("Failed to apply {Title}: {Error}", actionSet.Title, errorMessage); + errors.Add($"{actionSet.Title}: {errorMessage}"); + + if (actionSet.IsCrucialFix) + { + logger.LogError("Critical fix {Title} failed. Aborting remaining action sets.", actionSet.Title); + errors.Add($"Critical fix '{actionSet.Title}' failed. Remaining fixes were not applied."); + return ExecutionOutcome.FailedCritical; + } + + return ExecutionOutcome.FailedNonCritical; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error applying {Title}", actionSet.Title); + errors.Add($"{actionSet.Title}: {ex.Message}"); + + if (actionSet.IsCrucialFix) + { + logger.LogError(ex, "Critical fix {Title} threw unexpected exception. Aborting remaining action sets.", actionSet.Title); + errors.Add($"Critical fix '{actionSet.Title}' encountered an unexpected error. Remaining fixes were not applied."); + return ExecutionOutcome.FailedCritical; + } + + return ExecutionOutcome.FailedNonCritical; + } + } +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs new file mode 100644 index 000000000..689351ccb --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs @@ -0,0 +1,270 @@ +namespace GenHub.Core.Features.ActionSets; + +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Abstract base class for action sets, providing common functionality. +/// +public abstract class BaseActionSet(ILogger logger) : IActionSet +{ + /// + public abstract string Id { get; } + + /// + public abstract string Title { get; } + + /// + public virtual string Description => Title; + + /// + public virtual string DetailedDescription => string.Empty; + + /// + public virtual string Category => IsCoreFix ? "Core & Stability" : "Compatibility"; + + /// + public abstract bool IsCoreFix { get; } + + /// + public abstract bool IsCrucialFix { get; } + + /// + /// Gets the logger instance. + /// + protected ILogger Logger => logger; + + /// + /// + /// Default implementation returns true if either Generals or Zero Hour is detected in the installation. + /// Action sets that do not require a game installation should override this method. + /// + public virtual Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + => Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + + /// + public virtual Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + => Task.FromResult(false); + + /// + public async Task ApplyAsync(GameInstallation installation, CancellationToken ct = default) + { + logger.LogInformation("Applying ActionSet {Title} ({Id}) to {InstallationPath}...", Title, Id, installation.InstallationPath); + try + { + var result = await ApplyInternalAsync(installation, ct); + if (result.Success) + { + logger.LogInformation("Successfully applied ActionSet {Title} ({Id})", Title, Id); + } + else + { + logger.LogWarning("Failed to apply ActionSet {Title} ({Id}): {Error}", Title, Id, result.ErrorMessage); + } + + return result; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying ActionSet {Title} ({Id})", Title, Id); + return new ActionSetResult(false, ex.Message); + } + } + + /// + public async Task UndoAsync(GameInstallation installation, CancellationToken ct = default) + { + logger.LogInformation("Undoing ActionSet {Title} ({Id}) from {InstallationPath}...", Title, Id, installation.InstallationPath); + try + { + var result = await UndoInternalAsync(installation, ct); + if (result.Success) + { + logger.LogInformation("Successfully undid ActionSet {Title} ({Id})", Title, Id); + } + else + { + logger.LogWarning("Failed to undo ActionSet {Title} ({Id}): {Error}", Title, Id, result.ErrorMessage); + } + + return result; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing ActionSet {Title} ({Id})", Title, Id); + return new ActionSetResult(false, ex.Message); + } + } + + /// + /// Helper to return a successful result. + /// + /// A successful ActionSetResult. + protected static ActionSetResult Success() => new(true); + + /// + /// Helper to return a failed result. + /// + /// The error message. + /// A failed ActionSetResult. + protected static ActionSetResult Failure(string message) => new(false, message); + + /// + /// Checks if the marker file exists on disk. + /// + /// The marker file path. + /// true if the marker exists; otherwise, false. + protected static bool MarkerExists(string markerPath) => File.Exists(markerPath); + + /// + /// Writes a marker file with the current UTC timestamp. + /// + /// The marker file path. + protected static void WriteMarkerFile(string markerPath) + { + try + { + var dir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); + } + catch (IOException) + { + // Ignored - marker write non-fatal + } + catch (UnauthorizedAccessException) + { + // Ignored - marker write non-fatal + } + } + + /// + /// Safely reads all lines from a marker file. + /// + /// The marker file path. + /// The array of lines if read successfully; an empty array if the file does not exist; or null if reading failed due to an I/O error. + protected static string[]? ReadMarkerLinesSafely(string markerPath) + { + try + { + return File.Exists(markerPath) ? File.ReadAllLines(markerPath) : []; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Deletes a marker file if it exists on disk. + /// + /// The marker file path. + protected static void DeleteMarkerFile(string markerPath) + { + DeleteFileSafely(markerPath); + } + + /// + /// Safely deletes a file if it exists, clearing read-only attributes. + /// + /// The file path to delete. + protected static void DeleteFileSafely(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return; + } + + try + { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } + catch (IOException) + { + // Ignored - cleanup failure non-fatal + } + catch (UnauthorizedAccessException) + { + // Ignored - cleanup failure non-fatal + } + } + + /// + /// Safely deletes a directory and its contents if it exists, clearing read-only attributes. + /// + /// The directory path to delete. + protected static void DeleteDirectorySafely(string? path) + { + if (string.IsNullOrEmpty(path) || !Directory.Exists(path)) + { + return; + } + + try + { + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + try + { + File.SetAttributes(file, FileAttributes.Normal); + } + catch (IOException) + { + // Ignored - best-effort attribute reset before directory deletion + } + catch (UnauthorizedAccessException) + { + // Ignored - best-effort attribute reset before directory deletion + } + } + + Directory.Delete(path, true); + } + catch (IOException) + { + // Ignored - directory cleanup failure non-fatal + } + catch (UnauthorizedAccessException) + { + // Ignored - directory cleanup failure non-fatal + } + } + + /// + /// Implements the specific application logic. + /// + /// The game installation. + /// The cancellation token. + /// The result of the operation. + protected abstract Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct); + + /// + /// Implements the specific undo logic. + /// + /// The game installation. + /// The cancellation token. + /// The result of the operation. + protected abstract Task UndoInternalAsync(GameInstallation installation, CancellationToken ct); +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs new file mode 100644 index 000000000..9b839192e --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs @@ -0,0 +1,149 @@ +namespace GenHub.Core.Features.ActionSets; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.GameInstallations; + +/// +/// Defines a set of actions to fix or enhance a game installation. +/// +public interface IActionSet +{ + /// + /// Gets the unique identifier for this action set. + /// + string Id { get; } + + /// + /// Gets the title of the action set. + /// + string Title { get; } + + /// + /// Gets the concise user-facing description of what the action set does. + /// + string Description { get; } + + /// + /// Gets the detailed description explaining the technical mechanics, files modified, and problem solved. + /// + string DetailedDescription { get; } + + /// + /// Gets the category of the action set. + /// + string Category { get; } + + /// + /// Gets a value indicating whether this is a core fix applied by default. + /// + bool IsCoreFix { get; } + + /// + /// Gets a value indicating whether this is a crucial fix for game stability. + /// + bool IsCrucialFix { get; } + + /// + /// Checks if the action set is applicable to the current system and game installation. + /// + /// The game installation to check. + /// The cancellation token. + /// A task representing the asynchronous operation, returning true if applicable. + Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default); + + /// + /// Checks if the action set has already been applied. + /// + /// The game installation to check. + /// The cancellation token. + /// A task representing the asynchronous operation, returning true if applied. + Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default); + + /// + /// Applies the action set patches. + /// + /// The game installation to patch. + /// The cancellation token. + /// A task representing the asynchronous operation, returning the result of the action. + Task ApplyAsync(GameInstallation installation, CancellationToken ct = default); + + /// + /// Undoes the action set patches if possible. + /// + /// The game installation to revert. + /// The cancellation token. + /// A task representing the asynchronous operation, returning the result of the undo operation. + Task UndoAsync(GameInstallation installation, CancellationToken ct = default); +} + +/// +/// Represents the result of an action set operation. +/// +public record ActionSetResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether the operation succeeded. + /// Error message if the operation failed. + /// Detailed list of actions taken during the operation. + public ActionSetResult(bool success, string? errorMessage = null, IReadOnlyList? details = null) + { + Success = success; + ErrorMessage = errorMessage; + Details = details ?? []; + } + + /// + /// Gets a value indicating whether the operation succeeded. + /// + public bool Success { get; init; } + + /// + /// Gets the error message if the operation failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// Gets the detailed list of actions taken during the operation. + /// + public IReadOnlyList Details { get; init; } + + /// + /// Creates a new ActionSetResult with an additional detail message. + /// + /// The detail message to add. + /// A new ActionSetResult with the detail added. + public ActionSetResult WithDetail(string detail) + { + var newDetails = new List(Details) { detail }; + return new ActionSetResult(Success, ErrorMessage, newDetails); + } + + /// + /// Creates a successful result with the given details. + /// + /// The details of what was done. + /// A successful ActionSetResult. + public static ActionSetResult SuccessWithDetails(params string[] details) => + new(true, null, [.. details]); + + /// + /// Creates a failed result with the given error and optional details. + /// + /// The error message. + /// Optional details of what was attempted. + /// A failed ActionSetResult. + public static ActionSetResult FailureWithDetails(string error, params string[] details) => + new(false, error, [.. details]); + + /// + /// Formats the details as a multi-line string for display. + /// + /// A formatted string of all details. + public string FormatDetails() => Details.Count > 0 + ? string.Join("\n", Details) + : "No details available."; +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs new file mode 100644 index 000000000..71f47f3aa --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs @@ -0,0 +1,36 @@ +namespace GenHub.Core.Features.ActionSets; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; + +/// +/// Service responsible for managing and executing action sets. +/// +public interface IActionSetOrchestrator +{ + /// + /// Gets all registered action sets. + /// + /// A list of action sets. + IReadOnlyList GetAllActionSets(); + + /// + /// Gets applicable core fixes for a given installation. + /// + /// The game installation. + /// Cancellation token. + /// A task returning the list of applicable core fixes. + Task> GetApplicableCoreFixesAsync(GameInstallation installation, CancellationToken ct = default); + + /// + /// Applies a collection of action sets to an installation. + /// + /// The installation. + /// The action sets to apply. + /// Cancellation token. + /// Operation result containing details of success/failure. + Task> ApplyActionSetsAsync(GameInstallation installation, IEnumerable actionSets, CancellationToken ct = default); +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs new file mode 100644 index 000000000..302bd9b5c --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs @@ -0,0 +1,15 @@ +namespace GenHub.Core.Features.ActionSets; + +using System.Collections.Generic; + +/// +/// Defines a provider for discovering ActionSets. +/// +public interface IActionSetProvider +{ + /// + /// Gets the action sets provided by this source. + /// + /// A collection of action sets. + IEnumerable GetActionSets(); +} diff --git a/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs b/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs new file mode 100644 index 000000000..63c94bfad --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs @@ -0,0 +1,435 @@ +namespace GenHub.Core.Helpers; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Results; + +/// +/// Provides security validation for downloaded executables and packages, including SHA-256 and Authenticode checks. +/// +public static class DownloadSecurityValidator +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WinTrustFileInfo + { + private readonly uint _cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] + private readonly string _pszFilePath; + private readonly IntPtr _hFile; + private readonly IntPtr _pgKnownSubject; + + public WinTrustFileInfo(string filePath) + { + _cbStruct = (uint)Marshal.SizeOf(); + _pszFilePath = filePath; + _hFile = IntPtr.Zero; + _pgKnownSubject = IntPtr.Zero; + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WinTrustData + { + private readonly uint _cbStruct; + private readonly IntPtr _pPolicyCallbackData; + private readonly IntPtr _pSIPClientData; + private readonly uint _dwUIChoice; + private readonly uint _fdwRevocationChecks; + private readonly uint _dwUnionChoice; + private readonly IntPtr _pFile; + private readonly uint _dwStateAction; + private readonly IntPtr _hWVTStateData; + [MarshalAs(UnmanagedType.LPWStr)] + private readonly string? _pwszURLReference; + private readonly uint _dwProvFlags; + private readonly uint _dwUIContext; + private readonly IntPtr _pSignatureSettings; + + public WinTrustData(IntPtr filePtr) + { + _cbStruct = (uint)Marshal.SizeOf(); + _pPolicyCallbackData = IntPtr.Zero; + _pSIPClientData = IntPtr.Zero; + _dwUIChoice = 2; // WTD_UI_NONE + _fdwRevocationChecks = 1; // WTD_REVOKE_WHOLECHAIN + _dwUnionChoice = 1; // WTD_CHOICE_FILE + _pFile = filePtr; + _dwStateAction = 0; // WTD_STATEACTION_IGNORE + _hWVTStateData = IntPtr.Zero; + _pwszURLReference = null; + _dwProvFlags = 0x00000040; // WTD_CACHE_ONLY_URL_RETRIEVAL + _dwUIContext = 0; + _pSignatureSettings = IntPtr.Zero; + } + } + + private const int CertEExpired = unchecked((int)0x800B0101); + private const int CertEValidityPeriodNesting = unchecked((int)0x800B0102); + + private static readonly Guid WinTrustActionGenericVerifyV2 = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + + /// + /// Computes the SHA-256 hash of a file as a lowercase hexadecimal string. + /// + /// Path to the file to hash. + /// The cancellation token. + /// Lowercase hex SHA-256 string. + public static async Task ComputeSha256Async(string filePath, CancellationToken ct = default) + { + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true); + return await ComputeSha256Async(stream, ct); + } + + /// + /// Computes the SHA-256 hash of a stream as a lowercase hexadecimal string. + /// + /// The stream to hash. + /// The cancellation token. + /// Lowercase hex SHA-256 string. + public static async Task ComputeSha256Async(Stream stream, CancellationToken ct = default) + { + using var sha256 = SHA256.Create(); + var hashBytes = await sha256.ComputeHashAsync(stream, ct); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + /// + /// Validates the Authenticode signature and publisher of a file. + /// On Windows, performs WinVerifyTrust trust and integrity verification. + /// + /// Path to the executable or library file. + /// Expected publisher subject or issuer substring (e.g. "Microsoft Corporation"). + /// Whether to accept legacy expired certificates if publisher matches. + /// Operation result indicating success or failure. + public static OperationResult ValidateAuthenticodeSignature( + string filePath, + string? expectedPublisher = null, + bool allowExpiredCertificates = false) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return OperationResult.CreateFailure("File to validate does not exist."); + } + + // On non-Windows, Authenticode trust verification is not supported; fail closed + if (!OperatingSystem.IsWindows()) + { + return OperationResult.CreateFailure("Authenticode signature validation is only supported on Windows."); + } + + var trustResult = VerifyWindowsAuthenticodeTrust(filePath); + if (!trustResult.Success) + { + return OperationResult.CreateFailure(trustResult.Errors); + } + + int hresult = trustResult.Data; + if (hresult != 0) + { + bool isExpiredCert = hresult == CertEExpired || hresult == CertEValidityPeriodNesting; + if (!isExpiredCert || !allowExpiredCertificates) + { + return OperationResult.CreateFailure( + $"Authenticode trust verification failed for '{Path.GetFileName(filePath)}' with error code 0x{hresult:X8}."); + } + } + + if (!string.IsNullOrWhiteSpace(expectedPublisher)) + { + return VerifyPublisherMatch(filePath, expectedPublisher); + } + + return OperationResult.CreateSuccess(true); + } + + /// + /// Validates a downloaded file against pinned SHA-256 hashes and/or Authenticode publisher signatures. + /// Fails closed if any specified check fails. + /// + /// Path to the file to validate. + /// Optional list of allowed SHA-256 hashes. + /// Optional expected Authenticode publisher substring. + /// Whether to accept legacy expired certificates if publisher matches. + /// The cancellation token. + /// Operation result indicating validation success or failure. + public static async Task> ValidateFileAsync( + string filePath, + IReadOnlyList? allowedSha256Hashes = null, + string? expectedAuthenticodePublisher = null, + bool allowExpiredCertificates = false, + CancellationToken ct = default) + { + if (!File.Exists(filePath)) + { + return OperationResult.CreateFailure($"File '{filePath}' does not exist for validation."); + } + + bool hasHashCheck = allowedSha256Hashes is { Count: > 0 }; + bool hasPublisherCheck = !string.IsNullOrWhiteSpace(expectedAuthenticodePublisher); + + if (!hasHashCheck && !hasPublisherCheck) + { + return OperationResult.CreateFailure("No validation criteria (hash or publisher) specified."); + } + + // Check SHA-256 hash if specified + bool hashMatched = false; + if (allowedSha256Hashes is { Count: > 0 }) + { + var actualHash = await ComputeSha256Async(filePath, ct); + hashMatched = allowedSha256Hashes.Any(h => string.Equals(h, actualHash, StringComparison.OrdinalIgnoreCase)); + if (!hashMatched && !hasPublisherCheck) + { + return OperationResult.CreateFailure( + $"SHA-256 hash mismatch for '{Path.GetFileName(filePath)}'. Computed hash: '{actualHash}'. Expected one of: [{string.Join(", ", allowedSha256Hashes)}]."); + } + } + + // Check Authenticode publisher if specified + if (hasPublisherCheck) + { + var authResult = ValidateAuthenticodeSignature(filePath, expectedAuthenticodePublisher, allowExpiredCertificates); + if (!authResult.Success) + { + // If hash check was also specified and matched, allow fallback to known pinned hash + if (hasHashCheck && hashMatched) + { + return OperationResult.CreateSuccess(true); + } + + return authResult; + } + } + + return OperationResult.CreateSuccess(true); + } + + /// + /// Validates a file using SHA-256 hash and/or Authenticode signature checks, and returns a shared-read, locked stream if valid. + /// The caller is responsible for disposing the returned stream to release the file lock. + /// + /// The absolute path to the file to validate and lock. + /// Optional collection of allowed SHA-256 hashes (hex string, case-insensitive). + /// Optional expected publisher common name (CN) in Authenticode certificate. + /// Whether to accept expired certificates if valid at signing time. + /// Cancellation token. + /// A successful OperationResult containing the locked FileStream, or a failure result with validation errors. + public static async Task> ValidateAndLockFileAsync( + string filePath, + IReadOnlyList? allowedSha256Hashes = null, + string? expectedAuthenticodePublisher = null, + bool allowExpiredCertificates = false, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return OperationResult.CreateFailure("File path cannot be null or empty."); + } + + if (!File.Exists(filePath)) + { + return OperationResult.CreateFailure($"File '{filePath}' does not exist."); + } + + // Remove ReadOnly attribute if present so caller can overwrite/delete later if needed + try + { + var attributes = File.GetAttributes(filePath); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(filePath, attributes & ~FileAttributes.ReadOnly); + } + } + catch (IOException) + { + // Non-critical if filesystem does not support read-only attribute + } + catch (UnauthorizedAccessException) + { + // Non-critical if filesystem does not support read-only attribute + } + catch (ArgumentException) + { + // Non-critical if filesystem does not support read-only attribute + } + catch (NotSupportedException) + { + // Non-critical if filesystem does not support read-only attribute + } + + FileStream? stream = null; + try + { + stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, 81920, true); + + var verifyResult = await VerifyStreamHashAndSignatureAsync( + stream, + filePath, + allowedSha256Hashes, + expectedAuthenticodePublisher, + allowExpiredCertificates, + ct); + + if (!verifyResult.Success) + { + await stream.DisposeAsync(); + stream = null; + return OperationResult.CreateFailure(verifyResult.Errors); + } + + return OperationResult.CreateSuccess(stream); + } + catch (Exception ex) + { + if (stream != null) + { + await stream.DisposeAsync(); + } + + return OperationResult.CreateFailure($"Failed to validate and lock file '{filePath}': {ex.Message}"); + } + } + + private static async Task> VerifyStreamHashAndSignatureAsync( + FileStream stream, + string filePath, + IReadOnlyList? allowedSha256Hashes, + string? expectedAuthenticodePublisher, + bool allowExpiredCertificates, + CancellationToken ct) + { + bool hasHashCheck = allowedSha256Hashes is { Count: > 0 }; + bool hasPublisherCheck = !string.IsNullOrWhiteSpace(expectedAuthenticodePublisher); + + if (!hasHashCheck && !hasPublisherCheck) + { + return OperationResult.CreateFailure("No validation criteria (hash or publisher) specified."); + } + + bool hashMatched = false; + if (allowedSha256Hashes is { Count: > 0 }) + { + var actualHash = await ComputeSha256Async(stream, ct); + stream.Position = 0; + hashMatched = allowedSha256Hashes.Any(h => string.Equals(h, actualHash, StringComparison.OrdinalIgnoreCase)); + if (!hashMatched && !hasPublisherCheck) + { + return OperationResult.CreateFailure( + $"SHA-256 hash mismatch for '{Path.GetFileName(filePath)}'. Computed hash: '{actualHash}'. Expected one of: [{string.Join(", ", allowedSha256Hashes)}]."); + } + } + + if (hasPublisherCheck) + { + var authResult = ValidateAuthenticodeSignature(filePath, expectedAuthenticodePublisher, allowExpiredCertificates); + if (!authResult.Success) + { + if (hasHashCheck && hashMatched) + { + return OperationResult.CreateSuccess(true); + } + + return authResult; + } + } + + return OperationResult.CreateSuccess(true); + } + + private static OperationResult VerifyPublisherMatch(string filePath, string expectedPublisher) + { + try + { + using var cert = new X509Certificate2(X509Certificate.CreateFromSignedFile(filePath)); + + var subject = cert.Subject; + var issuer = cert.Issuer; + + if (!subject.Contains(expectedPublisher, StringComparison.OrdinalIgnoreCase) && + !issuer.Contains(expectedPublisher, StringComparison.OrdinalIgnoreCase)) + { + return OperationResult.CreateFailure( + $"Authenticode signature publisher mismatch. Expected publisher containing '{expectedPublisher}', but found subject '{subject}' and issuer '{issuer}'."); + } + + return OperationResult.CreateSuccess(true); + } + catch (CryptographicException ex) + { + return OperationResult.CreateFailure($"Authenticode certificate verification failed: {ex.Message}"); + } + catch (IOException ex) + { + return OperationResult.CreateFailure($"Authenticode certificate read failed: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + return OperationResult.CreateFailure($"Authenticode certificate access denied: {ex.Message}"); + } + } + + private static OperationResult VerifyWindowsAuthenticodeTrust(string filePath) + { + var fileInfo = new WinTrustFileInfo(Path.GetFullPath(filePath)); + + var pFileInfo = IntPtr.Zero; + var pData = IntPtr.Zero; + bool fileInfoMarshaled = false; + bool trustDataMarshaled = false; + + try + { + pFileInfo = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(fileInfo, pFileInfo, false); + fileInfoMarshaled = true; + + var trustData = new WinTrustData(pFileInfo); + + pData = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(trustData, pData, false); + trustDataMarshaled = true; + + int result = WinVerifyTrust(IntPtr.Zero, WinTrustActionGenericVerifyV2, pData); + return OperationResult.CreateSuccess(result); + } + catch (Exception ex) + { + return OperationResult.CreateFailure($"WinVerifyTrust exception: {ex.Message}"); + } + finally + { + if (trustDataMarshaled) + { + Marshal.DestroyStructure(pData); + } + + if (pData != IntPtr.Zero) + { + Marshal.FreeHGlobal(pData); + } + + if (fileInfoMarshaled) + { + Marshal.DestroyStructure(pFileInfo); + } + + if (pFileInfo != IntPtr.Zero) + { + Marshal.FreeHGlobal(pFileInfo); + } + } + } + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false, CharSet = CharSet.Unicode)] + private static extern int WinVerifyTrust( + IntPtr hwnd, + [MarshalAs(UnmanagedType.LPStruct)] Guid pgActionID, + IntPtr pWVTData); +} diff --git a/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs b/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs index f9dce3aba..a7d7aa327 100644 --- a/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs +++ b/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs @@ -37,4 +37,22 @@ public interface IShortcutService /// Optional custom name for the shortcut. If null, uses the profile name. /// The full path to the shortcut file. string GetShortcutPath(GameProfile profile, string? shortcutName = null); + + /// + /// Creates a shortcut at the specified path. + /// + /// The path where the shortcut will be created. + /// The path to the target executable. + /// Optional command line arguments. + /// Optional working directory. + /// Optional description. + /// Optional icon path. + /// An operation result indicating success or failure. + Task> CreateShortcutAsync( + string shortcutPath, + string targetPath, + string? arguments = null, + string? workingDirectory = null, + string? description = null, + string? iconPath = null); } diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs index 5bf1804e7..d54c56c99 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs @@ -22,8 +22,8 @@ public interface IToolRegistry /// Registers a new tool plugin with an assembly path (external tool). /// /// The tool plugin to register. - /// The path to the tool assembly. - void RegisterTool(IToolPlugin plugin, string assemblyPath); + /// The path to the tool assembly. Null for built-in tools. + void RegisterTool(IToolPlugin plugin, string? assemblyPath); /// /// Registers a new built-in tool plugin. diff --git a/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs b/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs new file mode 100644 index 000000000..29f3f9fe9 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs @@ -0,0 +1,26 @@ +namespace GenHub.Core.Messages; + +/// +/// Defines the type of tool status message. +/// +public enum MessageType +{ + /// Informational message. + Info, + + /// Success message. + Success, + + /// Error message. + Error, + + /// Warning message. + Warning, +} + +/// +/// Message sent when a tool's status changes. +/// +/// The status message. +/// The type of message. +public record ToolStatusMessage(string Message, MessageType Type = MessageType.Info); diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs index 3e0eca4f6..743b66944 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs @@ -15,5 +15,5 @@ public class GenPatcherCatalog /// /// Gets or sets the list of content items. /// - public List Items { get; set; } = new(); + public List Items { get; set; } = []; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs index ad0432121..7d0fd467d 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -303,6 +303,17 @@ public static class GenPatcherContentRegistry InstallTarget = ContentInstallTarget.Workspace, }, + ["genl"] = new GenPatcherContentMetadata + { + ContentCode = "genl", + DisplayName = "GenLauncher", + Description = "GenLauncher standalone launcher utility", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Category = GenPatcherContentCategory.Tools, + InstallTarget = ContentInstallTarget.Workspace, + }, + // Maps ["maod"] = new GenPatcherContentMetadata { diff --git a/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs b/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs index 3437c0f91..43c917c1a 100644 --- a/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs +++ b/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs @@ -5,46 +5,31 @@ namespace GenHub.Core.Models.Notifications; /// /// Represents a single action button on a notification. /// -public class NotificationAction +public class NotificationAction( + string text, + Action callback, + NotificationActionStyle style = NotificationActionStyle.Primary, + bool dismissOnExecute = true) { /// /// Gets the text to display on the action button. /// - public string Text { get; init; } + public string Text { get; init; } = text ?? throw new ArgumentNullException(nameof(text)); /// /// Gets the callback to execute when the action button is clicked. /// - public Action? Callback { get; private set; } + public Action? Callback { get; private set; } = callback ?? throw new ArgumentNullException(nameof(callback)); /// /// Gets the style of the action button. /// - public NotificationActionStyle Style { get; init; } + public NotificationActionStyle Style { get; init; } = style; /// /// Gets a value indicating whether the notification should be dismissed after executing this action. /// - public bool DismissOnExecute { get; init; } - - /// - /// Initializes a new instance of the class. - /// - /// The text to display on the action button. - /// The callback to execute when the action button is clicked. - /// The style of the action button. - /// Whether the notification should be dismissed after executing this action. - public NotificationAction( - string text, - Action callback, - NotificationActionStyle style = NotificationActionStyle.Primary, - bool dismissOnExecute = true) - { - Text = text ?? throw new ArgumentNullException(nameof(text)); - Callback = callback ?? throw new ArgumentNullException(nameof(callback)); - Style = style; - DismissOnExecute = dismissOnExecute; - } + public bool DismissOnExecute { get; init; } = dismissOnExecute; /// /// Clears the callback to prevent memory leaks. @@ -53,4 +38,4 @@ public void ClearCallback() { Callback = null; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs index 2c60d51bc..1759d3a50 100644 --- a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs +++ b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Notifications; @@ -97,7 +99,7 @@ public NotificationMessage( NotificationType type, string title, string message, - int? autoDismissMilliseconds = 5000, + int? autoDismissMilliseconds = GenHub.Core.Constants.NotificationDurations.Medium, string? actionText = null, Action? action = null, IReadOnlyList? actions = null, diff --git a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs index 180c828bc..25794a1c6 100644 --- a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs +++ b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs @@ -32,17 +32,17 @@ public IReadOnlyList GetAllTools() } /// - public void RegisterTool(IToolPlugin plugin, string assemblyPath) + public void RegisterTool(IToolPlugin plugin, string? assemblyPath) { _tools[plugin.Metadata.Id] = plugin; - _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath; + if (assemblyPath != null) + { + _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath; + } } /// - public void RegisterTool(IToolPlugin plugin) - { - _tools[plugin.Metadata.Id] = plugin; - } + public void RegisterTool(IToolPlugin plugin) => RegisterTool(plugin, null); /// public bool UnregisterTool(string toolId) diff --git a/GenHub/GenHub.Core/Services/Tools/ToolService.cs b/GenHub/GenHub.Core/Services/Tools/ToolService.cs index 7b4dbfa0c..5711cc43c 100644 --- a/GenHub/GenHub.Core/Services/Tools/ToolService.cs +++ b/GenHub/GenHub.Core/Services/Tools/ToolService.cs @@ -14,13 +14,13 @@ namespace GenHub.Core.Services.Tools; /// Plugin loader for loading tool plugins. /// Registry for managing tool plugins. /// Service for managing user settings. -/// Collection of built-in tool plugins. +/// Collection of built-in tool plugins from DI. /// Logger for logging tool service activities. public class ToolService( IToolPluginLoader pluginLoader, IToolRegistry toolRegistry, IUserSettingsService userSettingsService, - IEnumerable builtInPlugins, + IEnumerable builtInTools, ILogger logger) : IToolManager { @@ -34,20 +34,20 @@ public async Task> AddToolAsync(string assemblyPath if (!pluginLoader.ValidatePlugin(assemblyPath)) { logger.LogWarning("Tool plugin validation failed for: {AssemblyPath}", assemblyPath); - return await Task.FromResult(OperationResult.CreateFailure("Invalid tool plugin assembly.")); + return OperationResult.CreateFailure("Invalid tool plugin assembly."); } var plugin = pluginLoader.LoadPluginFromAssembly(assemblyPath); if (plugin == null) { logger.LogWarning("Failed to load tool plugin from assembly: {AssemblyPath}", assemblyPath); - return await Task.FromResult(OperationResult.CreateFailure("Failed to load tool plugin from assembly.")); + return OperationResult.CreateFailure("Failed to load tool plugin from assembly."); } if (toolRegistry.GetToolById(plugin.Metadata.Id) != null) { logger.LogWarning("Tool with ID {ToolId} is already registered", plugin.Metadata.Id); - return await Task.FromResult(OperationResult.CreateFailure("A tool with the same ID is already registered.")); + return OperationResult.CreateFailure("A tool with the same ID is already registered."); } toolRegistry.RegisterTool(plugin, assemblyPath); @@ -76,7 +76,7 @@ public async Task> AddToolAsync(string assemblyPath catch (Exception ex) { logger.LogError(ex, "An error occurred while adding tool plugin from assembly: {AssemblyPath}", assemblyPath); - return await Task.FromResult(OperationResult.CreateFailure("An error occurred while adding the tool plugin.")); + return OperationResult.CreateFailure("An error occurred while adding the tool plugin."); } } @@ -87,31 +87,31 @@ public IReadOnlyList GetAllTools() } /// - public async Task>> LoadSavedToolsAsync() + public Task>> LoadSavedToolsAsync() { try { var loadedPlugins = new List(); - // First, register all built-in plugins from DI - foreach (var builtInPlugin in builtInPlugins) + // 1. Register all built-in plugins from DI + foreach (var builtIn in builtInTools) { - var existingTool = toolRegistry.GetToolById(builtInPlugin.Metadata.Id); + var existingTool = toolRegistry.GetToolById(builtIn.Metadata.Id); if (existingTool == null) { - builtInPlugin.Metadata.IsBundled = true; - toolRegistry.RegisterTool(builtInPlugin); - loadedPlugins.Add(builtInPlugin); - logger.LogDebug("Registered built-in tool plugin: {PluginName}", builtInPlugin.Metadata.Name); + builtIn.Metadata.IsBundled = true; + toolRegistry.RegisterTool(builtIn); + loadedPlugins.Add(builtIn); + logger.LogDebug("Registered built-in tool plugin: {PluginName}", builtIn.Metadata.Name); } else { loadedPlugins.Add(existingTool); - logger.LogDebug("Built-in tool plugin {PluginName} already registered", builtInPlugin.Metadata.Name); + logger.LogDebug("Built-in tool plugin {PluginName} already registered", builtIn.Metadata.Name); } } - // Then, load external plugins from saved paths + // 2. Load external plugins from saved paths var settings = userSettingsService.Get(); var toolPaths = settings.InstalledToolAssemblyPaths ?? []; @@ -126,14 +126,18 @@ public async Task>> LoadSavedToolsAsync() { logger.LogDebug("Processing tool path: {Path}", path); - // Check if tool is already loaded in registry + // Check if tool is already loaded in registry by its path var existingTools = toolRegistry.GetAllTools(); var existingTool = existingTools.FirstOrDefault(t => toolRegistry.GetToolAssemblyPath(t.Metadata.Id) == path); if (existingTool != null) { // Tool already loaded, reuse it - loadedPlugins.Add(existingTool); + if (!loadedPlugins.Contains(existingTool)) + { + loadedPlugins.Add(existingTool); + } + logger.LogDebug("Tool plugin from {Path} already loaded, reusing existing instance.", path); continue; } @@ -155,14 +159,15 @@ public async Task>> LoadSavedToolsAsync() logger.LogInformation( "Loaded {Count} tool plugins ({BuiltIn} built-in, {External} external).", loadedPlugins.Count, - builtInPlugins.Count(), + builtInTools.Count(), toolPaths.Count); - return await Task.FromResult(OperationResult>.CreateSuccess(loadedPlugins)); + + return Task.FromResult(OperationResult>.CreateSuccess(loadedPlugins)); } catch (Exception ex) { logger.LogError(ex, "An error occurred while loading saved tool plugins."); - return await Task.FromResult(OperationResult>.CreateFailure("An error occurred while loading saved tool plugins.")); + return Task.FromResult(OperationResult>.CreateFailure("An error occurred while loading saved tool plugins.")); } } @@ -174,24 +179,24 @@ public async Task> RemoveToolAsync(string toolId) var tool = toolRegistry.GetToolById(toolId); if (tool == null) { - return await Task.FromResult(OperationResult.CreateFailure("Tool not found.")); + return OperationResult.CreateFailure("Tool not found."); } if (tool.Metadata.IsBundled) { logger.LogWarning("Attempted to remove bundled tool: {ToolName} ({ToolId})", tool.Metadata.Name, toolId); - return await Task.FromResult(OperationResult.CreateFailure("Bundled tools cannot be removed.")); + return OperationResult.CreateFailure("Bundled tools cannot be removed."); } var assemblyPath = toolRegistry.GetToolAssemblyPath(toolId); if (assemblyPath == null) { - return await Task.FromResult(OperationResult.CreateFailure("Tool registration is incomplete (missing assembly path).")); + return OperationResult.CreateFailure("Tool registration is incomplete (missing assembly path)."); } if (!toolRegistry.UnregisterTool(toolId)) { - return await Task.FromResult(OperationResult.CreateFailure("Failed to unregister tool.")); + return OperationResult.CreateFailure("Failed to unregister tool."); } userSettingsService.Update(settings => @@ -204,10 +209,10 @@ public async Task> RemoveToolAsync(string toolId) logger.LogInformation("Tool with ID {ToolId} removed successfully.", toolId); return OperationResult.CreateSuccess(true); } - catch + catch (Exception ex) { - logger.LogError("An error occurred while removing tool with ID: {ToolId}", toolId); + logger.LogError(ex, "An error occurred while removing tool with ID: {ToolId}", toolId); return OperationResult.CreateFailure("An error occurred while removing the tool."); } } -} +} \ No newline at end of file diff --git a/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs b/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs index 5c357c93b..d865f0f3e 100644 --- a/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs +++ b/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs @@ -120,6 +120,51 @@ public Task ShortcutExistsAsync(GameProfile profile) return Task.FromResult(File.Exists(shortcutPath)); } + /// + public Task> CreateShortcutAsync( + string shortcutPath, + string targetPath, + string? arguments = null, + string? workingDirectory = null, + string? description = null, + string? iconPath = null) + { + try + { + var directory = Path.GetDirectoryName(shortcutPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var name = Path.GetFileNameWithoutExtension(shortcutPath); + var comment = description ?? string.Empty; + + var desktopEntry = BuildDesktopEntry( + name, + comment, + targetPath, + arguments ?? string.Empty, + workingDirectory ?? string.Empty, + iconPath ?? string.Empty); + + File.WriteAllText(shortcutPath, desktopEntry, Encoding.UTF8); + MakeExecutable(shortcutPath); + + logger.LogInformation( + "Created shortcut at {ShortcutPath} targeting {TargetPath}", + shortcutPath, + targetPath); + + return Task.FromResult(OperationResult.CreateSuccess(true)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create shortcut at {ShortcutPath}", shortcutPath); + return Task.FromResult(OperationResult.CreateFailure($"Failed to create shortcut: {ex.Message}")); + } + } + /// public string GetShortcutPath(GameProfile profile, string? shortcutName = null) { diff --git a/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs b/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs index 36916aaf9..e1543cc19 100644 --- a/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs +++ b/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs @@ -81,6 +81,24 @@ public string GetShortcutPath(GameProfile profile, string? shortcutName = null) return Path.Combine(desktopPath, $"{AppConstants.AppName}-{name}{ShortcutExtension}"); } + /// + public Task> CreateShortcutAsync( + string shortcutPath, + string targetPath, + string? arguments = null, + string? workingDirectory = null, + string? description = null, + string? iconPath = null) + { + logger.LogWarning( + "Shortcut creation is not implemented on macOS yet for target {TargetPath}", + targetPath); + + return Task.FromResult( + OperationResult.CreateFailure( + "Shortcut creation is not implemented on macOS yet.")); + } + private static string SanitizeFileName(string fileName) { var sanitized = new StringBuilder(fileName); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs new file mode 100644 index 000000000..acde75ecb --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs @@ -0,0 +1,138 @@ +namespace GenHub.Tests.Core.Features.ActionSets; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class ActionSetOrchestratorTests +{ + private readonly Mock> _loggerMock = new(); + + /// + /// Verifies that when a fix fails in a batch, partial success count is returned in OperationResult.Data. + /// + /// A representing the test. + [Fact] + public async Task ApplyActionSetsAsync_WhenFixFails_ReturnsPartialSuccessCountAsync() + { + var fix1 = new Mock(); + fix1.SetupGet(f => f.Id).Returns("Fix1"); + fix1.SetupGet(f => f.Title).Returns("Fix 1"); + fix1.SetupGet(f => f.IsCrucialFix).Returns(false); + fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true)); + + var fix2 = new Mock(); + fix2.SetupGet(f => f.Id).Returns("Fix2"); + fix2.SetupGet(f => f.Title).Returns("Fix 2"); + fix2.SetupGet(f => f.IsCrucialFix).Returns(false); + fix2.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + fix2.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + fix2.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(false, "Fix2 failed")); + + var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object], [], _loggerMock.Object); + var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam); + + var result = await orchestrator.ApplyActionSetsAsync(installation, [fix1.Object, fix2.Object]); + + Assert.False(result.Success); + Assert.Equal(1, result.Data); + Assert.NotEmpty(result.Errors); + } + + /// + /// Verifies that when a crucial fix fails, sequence aborts and partial success count is returned. + /// + /// A representing the test. + [Fact] + public async Task ApplyActionSetsAsync_WhenCrucialFixFails_AbortsAndReturnsPartialSuccessCountAsync() + { + var fix1 = new Mock(); + fix1.SetupGet(f => f.Id).Returns("Fix1"); + fix1.SetupGet(f => f.Title).Returns("Fix 1"); + fix1.SetupGet(f => f.IsCrucialFix).Returns(false); + fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true)); + + var fix2 = new Mock(); + fix2.SetupGet(f => f.Id).Returns("CrucialFix2"); + fix2.SetupGet(f => f.Title).Returns("Crucial Fix 2"); + fix2.SetupGet(f => f.IsCrucialFix).Returns(true); + fix2.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + fix2.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + fix2.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(false, "Crucial failure")); + + var fix3 = new Mock(MockBehavior.Strict); + fix3.SetupGet(f => f.Id).Returns("Fix3"); + fix3.SetupGet(f => f.Title).Returns("Fix 3"); + + var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object, fix3.Object], [], _loggerMock.Object); + var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam); + + var result = await orchestrator.ApplyActionSetsAsync(installation, [fix1.Object, fix2.Object, fix3.Object]); + + Assert.False(result.Success); + Assert.Equal(1, result.Data); + Assert.Contains(result.Errors, e => e.Contains("Crucial Fix 2") && e.Contains("Remaining fixes were not applied")); + fix3.Verify(f => f.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that cancellation propagates OperationCanceledException. + /// + /// A representing the test. + [Fact] + public async Task ApplyActionSetsAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + var fix1 = new Mock(); + fix1.SetupGet(f => f.Id).Returns("Fix1"); + fix1.SetupGet(f => f.Title).Returns("Fix 1"); + fix1.SetupGet(f => f.IsCrucialFix).Returns(false); + fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var orchestrator = new ActionSetOrchestrator([fix1.Object], [], _loggerMock.Object); + var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam); + + await Assert.ThrowsAsync(() => + orchestrator.ApplyActionSetsAsync(installation, [fix1.Object], cts.Token)); + } + + /// + /// Verifies that duplicate action set IDs are deduplicated during initialization. + /// + [Fact] + public void InitializeActionSets_WithDuplicateIds_DeduplicatesSets() + { + var fix1 = new Mock(); + fix1.SetupGet(f => f.Id).Returns("DuplicateFix"); + fix1.SetupGet(f => f.Title).Returns("First Duplicate Fix"); + + var fix2 = new Mock(); + fix2.SetupGet(f => f.Id).Returns("DuplicateFix"); + fix2.SetupGet(f => f.Title).Returns("Second Duplicate Fix"); + + var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object], [], _loggerMock.Object); + var allSets = orchestrator.GetAllActionSets(); + + Assert.Single(allSets); + Assert.Equal("DuplicateFix", allSets[0].Id); + Assert.Equal("First Duplicate Fix", allSets[0].Title); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs index ba6f293c3..c9b670279 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs @@ -54,10 +54,6 @@ public GameInstallationServiceTests() return Task.FromResult(clientResult); }); - // Note: The service uses List, so the mock matches that concrete type. - _clientOrchestratorMock.Setup(x => x.DetectGameClientsFromInstallationsAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync(clientResult); - _service = new GameInstallationService( _orchestratorMock.Object, _clientOrchestratorMock.Object, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index 443b0f5cf..b7e44502c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -538,7 +538,9 @@ public static LauncherHarness Create( // Batch has no $$. PowerShell's own parent is the batch host, so it can report the // PID the harness needs. If PowerShell is unavailable the loop simply writes // nothing and Dispose falls back to leaving the launcher alone. - var recordPid = $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n"; + var recordPid = exitImmediately + ? string.Empty + : $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n"; // Leave the working directory afterwards: a batch host holds its current directory // open, which would defeat the cleanup delete for the launcher's whole lifetime. diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs index 6573acc2d..2339c3c33 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs @@ -174,7 +174,7 @@ public void DismissNotificationCommand_ShouldDismissNotification() } /// - /// Verifies that cleans up subscriptions. + /// Verifies that cleans up subscriptions. /// [Fact] public void Dispose_CleansUpSubscriptions() diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs new file mode 100644 index 000000000..5be6e07d4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs @@ -0,0 +1,183 @@ +namespace GenHub.Tests.Core.Helpers; + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using GenHub.Core.Helpers; +using Xunit; + +/// +/// Unit tests for . +/// +public class DownloadSecurityValidatorTests +{ + /// + /// Verifies that ValidateFileAsync succeeds when SHA-256 matches allowed hashes. + /// + /// A representing the test. + [Fact] + public async Task ValidateFileAsync_WhenSha256Matches_ReturnsSuccessAsync() + { + var tempFile = Path.GetTempFileName(); + try + { + var content = Encoding.UTF8.GetBytes("Test Content for Sha256"); + await File.WriteAllBytesAsync(tempFile, content); + + using var sha256 = SHA256.Create(); + var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant(); + + var result = await DownloadSecurityValidator.ValidateFileAsync( + tempFile, + allowedSha256Hashes: [expectedHash]); + + Assert.True(result.Success); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Verifies that ValidateFileAsync fails when SHA-256 does not match allowed hashes. + /// + /// A representing the test. + [Fact] + public async Task ValidateFileAsync_WhenSha256Mismatches_ReturnsFailureAsync() + { + var tempFile = Path.GetTempFileName(); + try + { + var content = Encoding.UTF8.GetBytes("Test Content for Sha256 Mismatch"); + await File.WriteAllBytesAsync(tempFile, content); + + var wrongHash = "0000000000000000000000000000000000000000000000000000000000000000"; + + var result = await DownloadSecurityValidator.ValidateFileAsync( + tempFile, + allowedSha256Hashes: [wrongHash]); + + Assert.False(result.Success); + Assert.Contains(result.Errors, e => e.Contains("SHA-256 hash mismatch")); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Verifies that ValidateAndLockFileAsync succeeds, sets read-only, and locks the file when SHA-256 matches. + /// + /// A representing the test. + [Fact] + public async Task ValidateAndLockFileAsync_WhenSha256Matches_ReturnsLockedStreamAsync() + { + var tempFile = Path.GetTempFileName(); + try + { + var content = Encoding.UTF8.GetBytes("Test Content for Lock Validation"); + await File.WriteAllBytesAsync(tempFile, content); + + using var sha256 = SHA256.Create(); + var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant(); + + var result = await DownloadSecurityValidator.ValidateAndLockFileAsync( + tempFile, + allowedSha256Hashes: [expectedHash]); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + + await using var stream = result.Data; + Assert.True(stream.CanRead); + Assert.False(stream.CanWrite); + } + finally + { + if (File.Exists(tempFile)) + { + try + { + File.SetAttributes(tempFile, FileAttributes.Normal); + File.Delete(tempFile); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + } + + /// + /// Verifies that ValidateAndLockFileAsync returns failure when hash mismatches. + /// + /// A representing the test. + [Fact] + public async Task ValidateAndLockFileAsync_WhenSha256Mismatches_ReturnsFailureAsync() + { + var tempFile = Path.GetTempFileName(); + try + { + var content = Encoding.UTF8.GetBytes("Mismatch Content for Lock Validation"); + await File.WriteAllBytesAsync(tempFile, content); + + var wrongHash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + var result = await DownloadSecurityValidator.ValidateAndLockFileAsync( + tempFile, + allowedSha256Hashes: [wrongHash]); + + Assert.False(result.Success); + Assert.Null(result.Data); + Assert.NotEmpty(result.Errors); + } + finally + { + if (File.Exists(tempFile)) + { + try + { + File.SetAttributes(tempFile, FileAttributes.Normal); + File.Delete(tempFile); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + } + + /// + /// Verifies that ComputeSha256Async from stream returns the correct hexadecimal hash. + /// + /// A representing the test. + [Fact] + public async Task ComputeSha256Async_FromStream_ReturnsExpectedHashAsync() + { + var content = Encoding.UTF8.GetBytes("Stream Hash Content"); + using var ms = new MemoryStream(content); + + using var sha256 = SHA256.Create(); + var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant(); + + var computedHash = await DownloadSecurityValidator.ComputeSha256Async(ms); + + Assert.Equal(expectedHash, computedHash); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs new file mode 100644 index 000000000..48fd8cab9 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs @@ -0,0 +1,84 @@ +namespace GenHub.Tests.Windows.Features.ActionSets; + +using System.Threading.Tasks; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Tests for the class. +/// +public class BaseActionSetTests +{ + private readonly Mock _loggerMock; + private readonly TestActionSet _testActionSet; + + /// + /// Initializes a new instance of the class. + /// + public BaseActionSetTests() + { + _loggerMock = new Mock(); + _testActionSet = new TestActionSet(_loggerMock.Object); + } + + /// + /// Verifies that ApplyAsync logs the action and calls the internal apply method. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ApplyAsync_LogsAndCallsInternalAsync() + { + var installation = new GameInstallation("C:\\Test", GenHub.Core.Models.Enums.GameInstallationType.Unknown); + + var result = await _testActionSet.ApplyAsync(installation); + + Assert.True(result.Success); + Assert.True(_testActionSet.ApplyCalled); + + // Verify logging happened (simplistic check) + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() != null && v.ToString()!.Contains("Applying ActionSet")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + + private class TestActionSet : BaseActionSet + { + public bool ApplyCalled { get; private set; } + + public TestActionSet(ILogger logger) + : base(logger) + { + } + + public override string Id => "Test"; + + public override string Title => "Test Action Set"; + + public override bool IsCoreFix => false; + + public override bool IsCrucialFix => false; + + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(true); + + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(false); + + protected override Task ApplyInternalAsync(GameInstallation installation, System.Threading.CancellationToken ct) + { + ApplyCalled = true; + return Task.FromResult(Success()); + } + + protected override Task UndoInternalAsync(GameInstallation installation, System.Threading.CancellationToken ct) + { + return Task.FromResult(Success()); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs new file mode 100644 index 000000000..94ec32f2c --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs @@ -0,0 +1,493 @@ +namespace GenHub.Tests.Windows.Features.ActionSets; + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Exceptions; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using Microsoft.Extensions.Logging; +using Moq; +using SharpCompress.Archives; +using Xunit; + +/// +/// Unit tests for transactional safety, rollback retention, and undo behavior in . +/// +public sealed class BasePackageDeploymentFixTests : IDisposable +{ + private readonly string _testDirectory; + private readonly Mock _loggerMock; + private readonly Mock _httpClientFactoryMock; + + /// + /// Initializes a new instance of the class. + /// + public BasePackageDeploymentFixTests() + { + _testDirectory = Path.Combine(Path.GetTempPath(), $"GenHub_PkgDeployTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDirectory); + _loggerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + } + + /// + /// Disposes the temporary test directory. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, true); + } + } + catch (IOException) + { + // Ignored on test cleanup + } + catch (UnauthorizedAccessException) + { + // Ignored on test cleanup + } + } + + /// + /// Verifies that when undo encounters a missing recorded backup file, + /// it does not delete the destination file (to prevent data loss) and returns failure. + /// + /// A representing the test operation. + [Fact] + public async Task Undo_WhenRecordedBackupIsMissing_RetainsDestinationFileAndFailsSafely() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstall"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var destFile = Path.Combine(installationPath, "game_asset.dll"); + await File.WriteAllTextAsync(destFile, "ImportantOriginalOrModifiedContent"); + + var backupDir = fix.PublicGetBackupDirectory(installation); + var missingBackupFile = Path.Combine(backupDir, "missing_backup.bak"); + var markerPath = fix.PublicGetMarkerPath(installation); + var markerDir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{missingBackupFile}"]); + + try + { + var result = await fix.UndoAsync(installation); + + result.Success.Should().BeFalse(); + File.Exists(destFile).Should().BeTrue("Destination file must be preserved when backup is missing"); + var content = await File.ReadAllTextAsync(destFile); + content.Should().Be("ImportantOriginalOrModifiedContent"); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } + + /// + /// Verifies that when a marker contains a destination path outside the game installation directory, + /// undo rejects modifying or deleting that arbitrary path and returns failure. + /// + /// A representing the test operation. + [Fact] + public async Task Undo_WhenDestPathIsOutsideInstallationDirectory_RejectsPathAndFailsSafely() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstallDestOutside"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var outsideDir = Path.Combine(_testDirectory, "OutsideFolder"); + Directory.CreateDirectory(outsideDir); + var outsideFile = Path.Combine(outsideDir, "critical_file.txt"); + await File.WriteAllTextAsync(outsideFile, "CriticalProtectedContent"); + + var markerPath = fix.PublicGetMarkerPath(installation); + var markerDir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + await File.WriteAllLinesAsync(markerPath, [$"{outsideFile}|"]); + + try + { + var result = await fix.UndoAsync(installation); + + result.Success.Should().BeFalse(); + File.Exists(outsideFile).Should().BeTrue("Arbitrary files outside installation directory must never be deleted"); + var content = await File.ReadAllTextAsync(outsideFile); + content.Should().Be("CriticalProtectedContent"); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } + + /// + /// Verifies that when a marker references a backup path outside the designated backup directory, + /// undo rejects copying that file into the installation directory. + /// + /// A representing the test operation. + [Fact] + public async Task Undo_WhenBackupPathIsOutsideBackupDirectory_RejectsBackupAndFailsSafely() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstallBackupOutside"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var destFile = Path.Combine(installationPath, "game_asset.dll"); + await File.WriteAllTextAsync(destFile, "CurrentInstalledContent"); + + var untrustedDir = Path.Combine(_testDirectory, "UntrustedLocation"); + Directory.CreateDirectory(untrustedDir); + var untrustedBackupFile = Path.Combine(untrustedDir, "payload.dll"); + await File.WriteAllTextAsync(untrustedBackupFile, "UntrustedPayloadContent"); + + var markerPath = fix.PublicGetMarkerPath(installation); + var markerDir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{untrustedBackupFile}"]); + + try + { + var result = await fix.UndoAsync(installation); + + result.Success.Should().BeFalse(); + File.Exists(destFile).Should().BeTrue(); + var content = await File.ReadAllTextAsync(destFile); + content.Should().Be("CurrentInstalledContent", "Destination must not be overwritten from untrusted path outside backup directory"); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + } + } + + /// + /// Verifies that when all recorded files are restored successfully, + /// the backup directory and marker are removed and original content is restored. + /// + /// A representing the test operation. + [Fact] + public async Task Undo_WhenRestorationSucceeds_RestoresOriginalsAndCleansUp() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstallSuccess"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var backupDir = fix.PublicGetBackupDirectory(installation); + Directory.CreateDirectory(backupDir); + + var destFile = Path.Combine(installationPath, "original.ini"); + var backupFile = Path.Combine(backupDir, "original.ini.bak"); + + await File.WriteAllTextAsync(destFile, "ModifiedByPatch"); + await File.WriteAllTextAsync(backupFile, "OriginalCleanGameContent"); + + var markerPath = fix.PublicGetMarkerPath(installation); + var markerDir = Path.GetDirectoryName(markerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{backupFile}"]); + + try + { + var result = await fix.UndoAsync(installation); + + result.Success.Should().BeTrue(); + File.Exists(destFile).Should().BeTrue(); + var restoredContent = await File.ReadAllTextAsync(destFile); + restoredContent.Should().Be("OriginalCleanGameContent"); + File.Exists(backupFile).Should().BeFalse(); + File.Exists(markerPath).Should().BeFalse(); + } + finally + { + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + } + + if (Directory.Exists(backupDir)) + { + Directory.Delete(backupDir, true); + } + } + } + + /// + /// Verifies that ExtractArchiveEntriesAsync successfully extracts multiple entries when their + /// cumulative decompressed size is within the allowed aggregate package size budget. + /// + /// A representing the test operation. + [Fact] + public async Task ExtractArchiveEntriesAsync_WhenEntriesAreWithinAggregateBudget_ExtractsAllEntriesSuccessfullyAsync() + { + var archivePath = Path.Combine(_testDirectory, "valid_multi_entry.zip"); + var extractDir = Path.Combine(_testDirectory, "extract_valid"); + Directory.CreateDirectory(extractDir); + + await CreateValidMultiEntryZipAsync(archivePath); + + using var archive = ArchiveFactory.OpenArchive(archivePath); + var extracted = await TestPackageDeploymentFix.PublicExtractArchiveEntriesAsync(archive, extractDir); + + extracted.Should().HaveCount(2); + File.Exists(Path.Combine(extractDir, "file1.dat")).Should().BeTrue(); + File.Exists(Path.Combine(extractDir, "file2.dat")).Should().BeTrue(); + } + + /// + /// Verifies that ExtractArchiveEntriesAsync tracks cumulative extracted bytes across entries and throws + /// when the multi-entry total exceeds the aggregate budget. + /// + /// A representing the test operation. + [Fact] + public async Task ExtractArchiveEntriesAsync_WhenCumulativeSizeExceedsAggregateBudget_ThrowsArchiveExpansionLimitExceededExceptionAsync() + { + var archivePath = Path.Combine(_testDirectory, "multi_entry_exceeding_budget.zip"); + var extractDir = Path.Combine(_testDirectory, "extract_exceeded"); + Directory.CreateDirectory(extractDir); + + await CreateOversizedMultiEntryZipAsync(archivePath); + + using var archive = ArchiveFactory.OpenArchive(archivePath); + var act = () => TestPackageDeploymentFix.PublicExtractArchiveEntriesAsync(archive, extractDir); + + await act.Should().ThrowAsync(); + + // Entry 1 was within the remaining budget and completed, whereas entry 2 exceeded the budget and was cleaned up. + File.Exists(Path.Combine(extractDir, "entry1.dat")).Should().BeTrue(); + File.Exists(Path.Combine(extractDir, "entry2.dat")).Should().BeFalse(); + } + + /// + /// Verifies that when a legacy global marker exists and scoped marker is missing, GetMarkerPath migrates + /// the global marker to the scoped marker and consumes the legacy global marker to prevent resurrection. + /// + [Fact] + public void GetMarkerPath_WhenLegacyGlobalMarkerExists_MigratesToScopedMarkerAndConsumesGlobalMarker() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstallLegacyMarker"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var scopedMarker = fix.PublicGetMarkerPath(installation); + var baseDir = Path.GetDirectoryName(scopedMarker)!; + Directory.CreateDirectory(baseDir); + + var globalMarker = Path.Combine(baseDir, "TestPackageDeploymentFix.done"); + if (File.Exists(scopedMarker)) + { + File.Delete(scopedMarker); + } + + File.WriteAllText(globalMarker, "legacy_content"); + + try + { + var resolvedPath = fix.PublicGetMarkerPath(installation); + + resolvedPath.Should().Be(scopedMarker); + File.Exists(scopedMarker).Should().BeTrue(); + File.ReadAllText(scopedMarker).Should().Be("legacy_content"); + File.Exists(globalMarker).Should().BeFalse("Legacy global marker must be moved to scoped marker to prevent resurrection"); + } + finally + { + if (File.Exists(scopedMarker)) + { + File.Delete(scopedMarker); + } + + if (File.Exists(globalMarker)) + { + File.Delete(globalMarker); + } + } + } + + /// + /// Verifies that when rollback executes for a failed batch, it only removes this batch's backup files + /// and preserves pre-existing backup files from prior deployments. + /// + [Fact] + public void Rollback_WhenPriorDeploymentBackupsExist_PreservesPriorBackupsInBackupDirectory() + { + var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object); + var installationPath = Path.Combine(_testDirectory, "GameInstallRollback"); + Directory.CreateDirectory(installationPath); + var installation = new GameInstallation(installationPath, GameInstallationType.Steam); + + var backupDir = fix.PublicGetBackupDirectory(installation); + Directory.CreateDirectory(backupDir); + + // Pre-existing backup from prior deployment + var priorBackupFile = Path.Combine(backupDir, "prior_backup.bak"); + File.WriteAllText(priorBackupFile, "PriorDeploymentOriginalContent"); + + // Current batch deployment entry that needs rollback + var destFile = Path.Combine(installationPath, "current_asset.ini"); + var currentBatchBackupFile = Path.Combine(backupDir, "current_batch_backup.bak"); + File.WriteAllText(destFile, "CurrentBatchModified"); + File.WriteAllText(currentBatchBackupFile, "CurrentBatchOriginal"); + + var backupEntries = new List<(string DestPath, bool ExistedBefore, string? BackupPath)> + { + (destFile, true, currentBatchBackupFile), + }; + var details = new List(); + + try + { + fix.PublicRollbackDeployment(backupEntries, backupDir, details); + + // Current batch destination should be restored + File.Exists(destFile).Should().BeTrue(); + File.ReadAllText(destFile).Should().Be("CurrentBatchOriginal"); + + // Current batch backup file should be deleted + File.Exists(currentBatchBackupFile).Should().BeFalse(); + + // Prior deployment backup file must still exist and backup directory must not be deleted + Directory.Exists(backupDir).Should().BeTrue("Backup directory must be retained when prior backups exist"); + File.Exists(priorBackupFile).Should().BeTrue("Prior deployment backup must not be destroyed by failed re-apply rollback"); + File.ReadAllText(priorBackupFile).Should().Be("PriorDeploymentOriginalContent"); + } + finally + { + if (Directory.Exists(backupDir)) + { + Directory.Delete(backupDir, true); + } + } + } + + private static async Task CreateValidMultiEntryZipAsync(string archivePath) + { + using var zipArchive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + await WriteZipEntryAsync(zipArchive, "file1.dat", new byte[1024]); + await WriteZipEntryAsync(zipArchive, "file2.dat", new byte[2048]); + } + + private static async Task CreateOversizedMultiEntryZipAsync(string archivePath) + { + var chunk = new byte[1024 * 1024]; + using var zipArchive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + await WriteRepeatedChunkZipEntryAsync(zipArchive, "entry1.dat", chunk, 110); + await WriteRepeatedChunkZipEntryAsync(zipArchive, "entry2.dat", chunk, 110); + } + + private static async Task WriteZipEntryAsync(ZipArchive archive, string entryName, byte[] content) + { + var entry = archive.CreateEntry(entryName); + await using var stream = entry.Open(); + await stream.WriteAsync(content); + } + + private static async Task WriteRepeatedChunkZipEntryAsync(ZipArchive archive, string entryName, byte[] chunk, int repetitions) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + await using var stream = entry.Open(); + for (var i = 0; i < repetitions; i++) + { + await stream.WriteAsync(chunk); + } + } + + private sealed class TestPackageDeploymentFix( + ILogger logger, + IHttpClientFactory httpClientFactory, + string customId = "TestPackageDeploymentFix") + : BasePackageDeploymentFix(httpClientFactory, logger, $"{customId}.done") + { + public override string Id => customId; + + public override string Title => "Test Package Fix"; + + public override string Description => "Test description"; + + public override bool IsCoreFix => false; + + public override bool IsCrucialFix => false; + + protected override string PackageDisplayName => "Test Package"; + + protected override string TempFilePrefix => "test_pkg"; + + protected override string ExpectedSha256 => "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + protected override IReadOnlyList DownloadUrls => ["https://example.com/test.zip"]; + + public static Task> PublicExtractArchiveEntriesAsync( + IArchive archive, + string extractDir, + CancellationToken ct = default) => ExtractArchiveEntriesAsync(archive, extractDir, ct); + + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(true); + + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(false); + + public string PublicGetMarkerPath(GameInstallation installation) => GetMarkerPath(installation); + + public string PublicGetBackupDirectory(GameInstallation installation) => GetBackupDirectory(installation); + + public void PublicRollbackDeployment( + List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries, + string backupDir, + List details) => RollbackDeployment(backupEntries, backupDir, details); + + protected override bool AreAssetsPresent(GameInstallation installation) => false; + + protected override List GetLegacyFilePaths(GameInstallation installation) => []; + + protected override Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync( + string archivePath, + DeploymentContext context, + GameInstallation installation, + CancellationToken ct) + { + return Task.FromResult<(int, List?)>((0, [])); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs new file mode 100644 index 000000000..884ac9b0b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs @@ -0,0 +1,233 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Tests for the class. +/// +public class EAAppRegistryFixTests +{ + private readonly Mock _registryMock; + private readonly Mock> _loggerMock; + private readonly EAAppRegistryFix _fix; + + /// + /// Initializes a new instance of the class. + /// + public EAAppRegistryFixTests() + { + _registryMock = new Mock(); + _registryMock.Setup(r => r.IsRunningAsAdministrator()).Returns(true); + + // Mock Set operations to return true (success) + _registryMock.Setup(r => r.SetStringValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + _registryMock.Setup(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + + _loggerMock = new Mock>(); + _fix = new EAAppRegistryFix(_registryMock.Object, _loggerMock.Object); + } + + /// + /// Verifies that IsApplicableAsync returns true when Generals registry keys are missing. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task IsApplicable_ReturnsTrue_WhenGeneralsKeysMissingAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + ZeroHourPath = "C:\\Games\\Zero Hour", + HasGenerals = true, + HasZeroHour = true, + }; + + // Mock Registry: Any call to GetStringValue for Install Path returns null (missing) + _registryMock.Setup(r => r.GetStringValue(It.IsAny(), RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns((string?)null); + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsApplicableAsync returns true when ergc registry keys are missing. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task IsApplicable_ReturnsTrue_WhenErgcMissingAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + ZeroHourPath = "C:\\Games\\Zero Hour", + HasGenerals = true, + HasZeroHour = true, + }; + + // Mock returns correct paths + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns(installation.GeneralsPath); + _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny())) + .Returns(65544); // 1.08 + + // Mock zero hour correct + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns(installation.ZeroHourPath); + _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName, It.IsAny())) + .Returns(65540); // 1.04 + + // Ergc missing (returns empty or null) + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny())) + .Returns(string.Empty); + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that ApplyAsync sets the correct registry keys. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task Apply_SetsRegistryKeysAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + ZeroHourPath = "C:\\Games\\Zero Hour", + HasGenerals = true, + HasZeroHour = true, + }; + + var result = await _fix.ApplyAsync(installation); + + Assert.True(result.Success); + + // Verify installs - Verify SET usage + _registryMock.Verify(r => r.SetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, installation.GeneralsPath, It.IsAny()), Times.Once); + _registryMock.Verify(r => r.SetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, RegistryConstants.GeneralsVersionDWord, It.IsAny()), Times.Once); + + // Verify serials logic - should attempt to write if missing (default mock returns null/empty so logic thinks it's missing) + _registryMock.Verify(r => r.SetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny(), It.IsAny()), Times.AtLeast(1)); + } + + /// + /// Verifies that IsApplicableAsync returns true for EA App installations. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task IsApplicable_ReturnsTrue_ForEaAppInstallationAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + ZeroHourPath = "C:\\Games\\Zero Hour", + HasGenerals = true, + HasZeroHour = true, + }; + + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns(installation.GeneralsPath); + _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny())) + .Returns(RegistryConstants.GeneralsVersionDWord); + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny())) + .Returns("VALIDSERIAL12345678"); + + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns(installation.ZeroHourPath); + _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName, It.IsAny())) + .Returns(RegistryConstants.ZeroHourVersionDWord); + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty, It.IsAny())) + .Returns("VALIDSERIAL87654321"); + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsApplicableAsync returns false when installation type is not EA App or Unknown. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task IsApplicable_ReturnsFalse_WhenNotEaAppInstallationAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) + { + GeneralsPath = "C:\\Games\\Generals", + HasGenerals = true, + }; + + var result = await _fix.IsApplicableAsync(installation); + + Assert.False(result); + } + + /// + /// Verifies that IsAppliedAsync returns true when all keys are present and valid, and false otherwise. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task IsApplied_ReturnsTrue_WhenAllKeysValid_AndFalseWhenMissingAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + HasGenerals = true, + HasZeroHour = false, + }; + + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny())) + .Returns(installation.GeneralsPath); + _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny())) + .Returns(RegistryConstants.GeneralsVersionDWord); + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny())) + .Returns("VALIDSERIAL"); + + var appliedResult = await _fix.IsAppliedAsync(installation); + Assert.True(appliedResult); + + // Missing serial + _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny())) + .Returns((string?)null); + + var unappliedResult = await _fix.IsAppliedAsync(installation); + Assert.False(unappliedResult); + } + + /// + /// Verifies that ApplyAsync returns failure when setting registry keys fails. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task Apply_ReturnsFailure_WhenSetStringValueFailsAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp) + { + GeneralsPath = "C:\\Games\\Generals", + HasGenerals = true, + HasZeroHour = false, + }; + + _registryMock.Setup(r => r.SetStringValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(false); + + var result = await _fix.ApplyAsync(installation); + + Assert.False(result.Success); + Assert.Contains("Failed to write", result.ErrorMessage); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs new file mode 100644 index 000000000..2b1d4196e --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs @@ -0,0 +1,258 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class ExpandedLANLobbyMenuTests : IDisposable +{ + private readonly Mock _httpClientFactoryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly string _testDir; + private readonly ExpandedLanLobbyMenu _fix; + + /// + /// Initializes a new instance of the class. + /// + public ExpandedLANLobbyMenuTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"ExpandedLANLobbyMenuTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done"); + _fix = new ExpandedLanLobbyMenu(_httpClientFactoryMock.Object, _loggerMock.Object, markerPath); + } + + /// + public void Dispose() + { + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + catch + { + // Ignore cleanup failures + } + } + + /// + /// Verifies properties return expected defaults. + /// + [Fact] + public void Properties_ReturnExpectedDefaults() + { + Assert.Equal("ExpandedLANLobbyMenu", _fix.Id); + Assert.Equal("Expanded LAN Lobby Menu (Addon)", _fix.Title); + Assert.Equal(ActionSetConstants.Categories.QualityOfLife, _fix.Category); + Assert.False(_fix.IsCoreFix); + Assert.False(_fix.IsCrucialFix); + } + + /// + /// Verifies that IsApplicableAsync returns true when either game component is present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsApplicableAsync_WhenGeneralsOrZeroHourPresent_ReturnsTrueAsync() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = _testDir, + }; + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsAppliedAsync returns false when no marker or custom window files exist. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenNoFilesPresent_ReturnsFalseAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.IsAppliedAsync(installation); + + Assert.False(result); + } + + /// + /// Verifies that IsAppliedAsync returns true when a custom BIG file exists in the installation. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenCustomBigExists_ReturnsTrueAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + File.WriteAllText(Path.Combine(zhDir, "!ExpandedLANMenu.big"), "content"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.IsAppliedAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that UndoAsync removes recorded custom window files and marker when marker exists. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenMarkerExists_RemovesRecordedFilesAndMarkerAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big"); + File.WriteAllText(bigFile, "content"); + + var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done"); + File.WriteAllLines(markerPath, [bigFile]); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(bigFile)); + Assert.False(File.Exists(markerPath)); + } + + /// + /// Verifies that UndoAsync succeeds when no marker exists and no files are present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNoMarkerExistsAndNoFilesPresent_SucceedsAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + } + + /// + /// Verifies that UndoAsync deletes only recorded files when an unrecorded known file is also present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenMarkerExistsAndUnrecordedFilePresent_LeavesUnrecordedFileIntactAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + var recordedFile = Path.Combine(zhDir, "!ExpandedLANMenu.big"); + var unrecordedFile = Path.Combine(zhDir, "CustomWindows.big"); + File.WriteAllText(recordedFile, "content1"); + File.WriteAllText(unrecordedFile, "content2"); + + var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done"); + File.WriteAllLines(markerPath, [recordedFile]); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(recordedFile)); + Assert.True(File.Exists(unrecordedFile)); + Assert.False(File.Exists(markerPath)); + } + + /// + /// Verifies that UndoAsync returns a warning failure when files are present on disk but no marker exists. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNoMarkerExistsAndFilesPresent_ReturnsWarningFailureAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big"); + File.WriteAllText(bigFile, "content"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.False(result.Success); + Assert.True(File.Exists(bigFile)); + Assert.Contains("No deployment marker found", result.ErrorMessage ?? string.Empty); + } + + /// + /// Verifies that UndoAsync migrates legacy timestamp markers and removes recognized custom window files. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenLegacyTimestampMarkerExists_MigratesAndRemovesRecognizedFilesAsync() + { + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(zhDir); + var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big"); + File.WriteAllText(bigFile, "content"); + + var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done"); + File.WriteAllText(markerPath, "2024-01-01T00:00:00Z"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(bigFile)); + Assert.False(File.Exists(markerPath)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs new file mode 100644 index 000000000..5488c9a15 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs @@ -0,0 +1,58 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class FirewallExceptionFixTests +{ + private readonly Mock> _loggerMock = new(); + private readonly FirewallExceptionFix _fix; + + /// + /// Initializes a new instance of the class. + /// + public FirewallExceptionFixTests() + { + _fix = new FirewallExceptionFix(_loggerMock.Object); + } + + /// + /// Verifies properties return expected defaults. + /// + [Fact] + public void Properties_ReturnExpectedDefaults() + { + Assert.Equal("FirewallExceptionFix", _fix.Id); + Assert.Equal("Windows Firewall Exceptions", _fix.Title); + Assert.Equal(ActionSetConstants.Categories.Multiplayer, _fix.Category); + Assert.False(_fix.IsCoreFix); + Assert.False(_fix.IsCrucialFix); + } + + /// + /// Verifies that IsApplicableAsync returns true for installations with Generals or Zero Hour. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsApplicableAsync_WhenGamePresent_ReturnsTrueAsync() + { + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = "C:\\Games\\Generals", + }; + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs new file mode 100644 index 000000000..04889c8b8 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs @@ -0,0 +1,389 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class HDIconsFixTests : IDisposable +{ + private readonly Mock _httpClientFactoryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly string _testDir; + private readonly HDIconsFix _fix; + + /// + /// Initializes a new instance of the class. + /// + public HDIconsFixTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"HDIconsFixTests_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + var markerPath = Path.Combine(_testDir, "HDIconsFix.done"); + _fix = new HDIconsFix(_httpClientFactoryMock.Object, _loggerMock.Object, markerPath); + } + + /// + public void Dispose() + { + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, recursive: true); + } + } + catch + { + // Ignore cleanup failures + } + } + + /// + /// Verifies properties return expected defaults. + /// + [Fact] + public void Properties_ReturnExpectedDefaults() + { + Assert.Equal("HDIconsFix", _fix.Id); + Assert.Equal("HD Icons (Addon)", _fix.Title); + Assert.Equal(ActionSetConstants.Categories.QualityOfLife, _fix.Category); + Assert.False(_fix.IsCoreFix); + Assert.False(_fix.IsCrucialFix); + } + + /// + /// Verifies that IsApplicableAsync returns true when either game component is present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsApplicableAsync_WhenGeneralsOrZeroHourPresent_ReturnsTrueAsync() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = _testDir, + }; + + var result = await _fix.IsApplicableAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsAppliedAsync returns true when HD icon files exist in the installation directory. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenIconsExist_ReturnsTrueAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + var zhDir = Path.Combine(_testDir, "ZeroHour"); + Directory.CreateDirectory(genDir); + Directory.CreateDirectory(zhDir); + + File.WriteAllText(Path.Combine(genDir, "GeneralsHD.ico"), "icon"); + File.WriteAllText(Path.Combine(zhDir, "GeneralsZHHD.ico"), "icon"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + HasZeroHour = true, + ZeroHourPath = zhDir, + }; + + var result = await _fix.IsAppliedAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsAppliedAsync returns false when HD icon files are missing. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenIconsMissing_ReturnsFalseAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.IsAppliedAsync(installation); + + Assert.False(result); + } + + /// + /// Verifies that UndoAsync deletes recorded HD icon files and marker when marker exists. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenMarkerExists_DeletesFilesAndReturnsSuccessAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + var iconPath = Path.Combine(genDir, "GeneralsHD.ico"); + File.WriteAllText(iconPath, "icon"); + + var markerPath = Path.Combine(_testDir, "HDIconsFix.done"); + File.WriteAllLines(markerPath, [iconPath]); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(iconPath)); + Assert.False(File.Exists(markerPath)); + } + + /// + /// Verifies that UndoAsync succeeds when no marker exists and no files are present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNoMarkerExistsAndNoFilesPresent_SucceedsAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + } + + /// + /// Verifies that ValidateArchiveContents returns false when archive is empty. + /// + [Fact] + public void ValidateArchiveContents_WhenArchiveEmpty_ReturnsFalse() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = _testDir, + }; + + var result = HDIconsFix.ValidateArchiveContents(new HashSet(), installation); + + Assert.False(result.IsValid); + Assert.Equal("HD icons archive contains no valid files.", result.FirstError); + } + + /// + /// Verifies that ValidateArchiveContents returns false when Generals icon is missing. + /// + [Fact] + public void ValidateArchiveContents_WhenGeneralsInstalledAndMissingIcon_ReturnsFalse() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = _testDir, + }; + + var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "Unrecognized.ico" }; + var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation); + + Assert.False(result.IsValid); + Assert.Equal("HD icons package does not contain a recognized icon for Generals.", result.FirstError); + } + + /// + /// Verifies that ValidateArchiveContents returns false when Zero Hour icon is missing. + /// + [Fact] + public void ValidateArchiveContents_WhenZeroHourInstalledAndMissingIcon_ReturnsFalse() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = _testDir, + }; + + var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "Unrecognized.ico" }; + var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation); + + Assert.False(result.IsValid); + Assert.Equal("HD icons package does not contain a recognized icon for Zero Hour.", result.FirstError); + } + + /// + /// Verifies that ValidateArchiveContents fails for Zero Hour when only GeneralsHD.ico is present. + /// + [Fact] + public void ValidateArchiveContents_WhenZeroHourInstalledAndOnlyGeneralsIconPresent_ReturnsFalse() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = _testDir, + }; + + var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "GeneralsHD.ico" }; + var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation); + + Assert.False(result.IsValid); + Assert.Equal("HD icons package does not contain a recognized icon for Zero Hour.", result.FirstError); + } + + /// + /// Verifies that ValidateArchiveContents returns true when required icons are present. + /// + [Fact] + public void ValidateArchiveContents_WhenAllRequiredIconsPresent_ReturnsTrue() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = _testDir, + HasZeroHour = true, + ZeroHourPath = _testDir, + }; + + var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GeneralsHD.ico", + "GeneralsZHHD.ico", + }; + var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation); + + Assert.True(result.IsValid); + Assert.Null(result.FirstError); + } + + /// + /// Verifies that ValidateArchiveContents matches icons case-insensitively. + /// + [Fact] + public void ValidateArchiveContents_CaseInsensitiveMatching_ReturnsTrue() + { + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = _testDir, + HasZeroHour = true, + ZeroHourPath = _testDir, + }; + + var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "generalshd.ico", + "generalszhhd.ico", + }; + var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation); + + Assert.True(result.IsValid); + Assert.Null(result.FirstError); + } + + /// + /// Verifies that UndoAsync deletes only recorded files when an unrecorded known file is also present. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenMarkerExistsAndUnrecordedFilePresent_LeavesUnrecordedFileIntactAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + var recordedFile = Path.Combine(genDir, "GeneralsHD.ico"); + var unrecordedFile = Path.Combine(genDir, "game_hd.ico"); + File.WriteAllText(recordedFile, "icon1"); + File.WriteAllText(unrecordedFile, "icon2"); + + var markerPath = Path.Combine(_testDir, "HDIconsFix.done"); + File.WriteAllLines(markerPath, [recordedFile]); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(recordedFile)); + Assert.True(File.Exists(unrecordedFile)); + Assert.False(File.Exists(markerPath)); + } + + /// + /// Verifies that UndoAsync returns a warning failure when files are present on disk but no marker exists. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNoMarkerExistsAndFilesPresent_ReturnsWarningFailureAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + var iconPath = Path.Combine(genDir, "GeneralsHD.ico"); + File.WriteAllText(iconPath, "icon"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.False(result.Success); + Assert.True(File.Exists(iconPath)); + Assert.Contains("No deployment marker found", result.ErrorMessage ?? string.Empty); + } + + /// + /// Verifies that UndoAsync migrates legacy timestamp markers and removes recognized icon files. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenLegacyTimestampMarkerExists_MigratesAndRemovesRecognizedFilesAsync() + { + var genDir = Path.Combine(_testDir, "Generals"); + Directory.CreateDirectory(genDir); + var iconPath = Path.Combine(genDir, "GeneralsHD.ico"); + File.WriteAllText(iconPath, "icon"); + + var markerPath = Path.Combine(_testDir, "HDIconsFix.done"); + File.WriteAllText(markerPath, "2024-01-01T00:00:00Z"); + + var installation = new GameInstallation(_testDir, GameInstallationType.Steam) + { + HasGenerals = true, + GeneralsPath = genDir, + }; + + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + Assert.False(File.Exists(iconPath)); + Assert.False(File.Exists(markerPath)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs new file mode 100644 index 000000000..1676716ae --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs @@ -0,0 +1,47 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System.IO; +using System.Threading.Tasks; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class OneDriveFixTests +{ + private readonly Mock> _loggerMock = new(); + + /// + /// Verifies properties and basic instantiation. + /// + [Fact] + public void Properties_ReturnExpectedDefaults() + { + var fix = new OneDriveFix(_loggerMock.Object); + + Assert.Equal("OneDriveFix", fix.Id); + Assert.Equal("Prevent OneDrive Sync (Move & Symlink)", fix.Title); + Assert.False(fix.IsCoreFix); + Assert.False(fix.IsCrucialFix); + } + + /// + /// Verifies that Undo returns success when no backups exist. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNoBackupsExist_ReturnsSuccessAsync() + { + var fix = new OneDriveFix(_loggerMock.Object); + var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam); + + var result = await fix.UndoAsync(installation); + + Assert.True(result.Success); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs new file mode 100644 index 000000000..c66f31632 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs @@ -0,0 +1,126 @@ +namespace GenHub.Tests.Windows.Features.ActionSets.Fixes; + +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Fixes; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +/// +/// Unit tests for . +/// +public class PreferIPv4FixTests +{ + private readonly Mock _registryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly PreferIPv4Fix _fix; + + /// + /// Initializes a new instance of the class. + /// + public PreferIPv4FixTests() + { + _registryMock.Setup(r => r.IsRunningAsAdministrator()).Returns(true); + _registryMock.Setup(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + _registryMock.Setup(r => r.DeleteValue(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + + _fix = new PreferIPv4Fix(_registryMock.Object, _loggerMock.Object); + } + + /// + /// Verifies properties return expected defaults. + /// + [Fact] + public void Properties_ReturnExpectedDefaults() + { + Assert.Equal("PreferIPv4Fix", _fix.Id); + Assert.Equal("Prefer IPv4", _fix.Title); + Assert.Equal(ActionSetConstants.Categories.Multiplayer, _fix.Category); + Assert.False(_fix.IsCoreFix); + Assert.False(_fix.IsCrucialFix); + } + + /// + /// Verifies that IsAppliedAsync returns true when DisabledComponents matches expected value. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenRegistryMatches_ReturnsTrueAsync() + { + _registryMock.Setup(r => r.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + It.IsAny())) + .Returns(RegistryConstants.PreferIPv4DisabledComponentsValue); + + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true }; + var result = await _fix.IsAppliedAsync(installation); + + Assert.True(result); + } + + /// + /// Verifies that IsAppliedAsync returns false when DisabledComponents is missing or 0. + /// + /// A representing the asynchronous test. + [Fact] + public async Task IsAppliedAsync_WhenRegistryMissing_ReturnsFalseAsync() + { + _registryMock.Setup(r => r.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + It.IsAny())) + .Returns((int?)null); + + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true }; + var result = await _fix.IsAppliedAsync(installation); + + Assert.False(result); + } + + /// + /// Verifies that ApplyAsync returns success when already configured. + /// + /// A representing the asynchronous test. + [Fact] + public async Task ApplyAsync_WhenAlreadyApplied_ReturnsSuccessAsync() + { + _registryMock.Setup(r => r.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + It.IsAny())) + .Returns(RegistryConstants.PreferIPv4DisabledComponentsValue); + + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true }; + var result = await _fix.ApplyAsync(installation); + + Assert.True(result.Success); + _registryMock.Verify(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that UndoAsync returns success when not configured. + /// + /// A representing the asynchronous test. + [Fact] + public async Task UndoAsync_WhenNotConfigured_ReturnsSuccessAsync() + { + _registryMock.Setup(r => r.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + It.IsAny())) + .Returns((int?)null); + + var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true }; + var result = await _fix.UndoAsync(installation); + + Assert.True(result.Success); + _registryMock.Verify(r => r.DeleteValue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs new file mode 100644 index 000000000..2c2d8cd70 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs @@ -0,0 +1,210 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// Fix that applies Windows compatibility flags (Run as Admin, High DPI) for game executables. +/// +public class AppCompatConfigurationsFix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + private static readonly IReadOnlyList GeneralsExecutables = ["Generals.exe", "generals.exe", "generalsv.exe"]; + private static readonly IReadOnlyList ZeroHourExecutables = ["Generals.exe", "generals.exe", "generalszh.exe", "GeneralsOnlineZH.exe", "GeneralsOnlineZH_30.exe", "GeneralsOnlineZH_60.exe"]; + + /// + public override string Id => "AppCompatConfigurationsFix"; + + /// + public override string Title => "Windows Compatibility Configurations"; + + /// + public override string Description => "Sets Windows compatibility flags (RUNASADMIN and HIGHDPIAWARE) to prevent startup crashes and DPI scaling distortion."; + + /// + public override string DetailedDescription => "Registers HIGHDPIAWARE and RUNASADMIN flags in the Windows AppCompat registry for all Generals and Zero Hour binaries (automatically differentiating Steam vs. non-Steam installations). This ensures the game renders at native monitor resolution without blurry scaling or privilege errors."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + string expectedFlag = installation.InstallationType == GameInstallationType.Steam + ? "~ HIGHDPIAWARE" + : "~ RUNASADMIN HIGHDPIAWARE"; + + bool generalsApplied = !installation.HasGenerals || AreFlagsApplied(installation.GeneralsPath, GeneralsExecutables, expectedFlag); + bool zhApplied = !installation.HasZeroHour || AreFlagsApplied(installation.ZeroHourPath, ZeroHourExecutables, expectedFlag); + + return Task.FromResult(generalsApplied && zhApplied); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting Windows compatibility configuration..."); + + string flag = installation.InstallationType == GameInstallationType.Steam + ? "~ HIGHDPIAWARE" + : "~ RUNASADMIN HIGHDPIAWARE"; + + details.Add($"Installation type: {installation.InstallationType}"); + details.Add($"Compatibility flags: {flag}"); + details.Add(string.Empty); + + bool allSucceeded = true; + + if (installation.HasGenerals) + { + details.Add($"Processing Generals executables: {installation.GeneralsPath}"); + var ok = await ProcessExecutablesAsync(installation.GeneralsPath, GeneralsExecutables, flag, details, ct); + if (!ok) allSucceeded = false; + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour executables: {installation.ZeroHourPath}"); + var ok = await ProcessExecutablesAsync(installation.ZeroHourPath, ZeroHourExecutables, flag, details, ct); + if (!ok) allSucceeded = false; + } + + if (!allSucceeded) + { + return new ActionSetResult(false, "Failed to apply compatibility flags to one or more executables.", details); + } + + details.Add("✓ Windows compatibility configuration completed successfully"); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to apply AppCompat configurations"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + try + { + details.Add("Removing Windows compatibility registry flags..."); + + if (installation.HasGenerals) + { + foreach (var exe in GeneralsExecutables) + { + var fullPath = Path.Combine(installation.GeneralsPath, exe); + if (registryService.DeleteValue(RegistryConstants.AppCompatLayersKeyPath, fullPath)) + { + details.Add($" ✓ Removed compatibility flags for: {exe}"); + } + } + } + + if (installation.HasZeroHour) + { + foreach (var exe in ZeroHourExecutables) + { + var fullPath = Path.Combine(installation.ZeroHourPath, exe); + if (registryService.DeleteValue(RegistryConstants.AppCompatLayersKeyPath, fullPath)) + { + details.Add($" ✓ Removed compatibility flags for: {exe}"); + } + } + } + + details.Add("✓ Compatibility flags removed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to undo AppCompat configurations"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private bool AreFlagsApplied(string? basePath, IReadOnlyList executables, string expectedFlag) + { + if (string.IsNullOrEmpty(basePath) || !Directory.Exists(basePath)) + { + return true; + } + + foreach (var exe in executables) + { + var fullPath = Path.Combine(basePath, exe); + if (File.Exists(fullPath)) + { + var current = registryService.GetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath); + if (current != expectedFlag) + { + return false; + } + } + } + + return true; + } + + private Task ProcessExecutablesAsync(string installPath, IReadOnlyList executables, string flag, List details, CancellationToken ct) + { + int processedCount = 0; + bool allSucceeded = true; + + foreach (var exe in executables) + { + ct.ThrowIfCancellationRequested(); + + var fullPath = Path.Combine(installPath, exe); + if (!File.Exists(fullPath)) continue; + + // Set Registry AppCompat Flag + try + { + if (registryService.SetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath, flag)) + { + details.Add($" ✓ Set compatibility flags for: {exe}"); + processedCount++; + } + else + { + allSucceeded = false; + details.Add($" ✗ Failed to set flags for: {exe}"); + } + } + catch (Exception ex) + { + allSucceeded = false; + logger.LogWarning(ex, "Failed to set registry flag for {Path}", fullPath); + details.Add($" ✗ Failed to set flags for: {exe}"); + } + } + + details.Add($"✓ Processed {processedCount} executables"); + return Task.FromResult(allSucceeded); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs new file mode 100644 index 000000000..3f42aeb2f --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs @@ -0,0 +1,167 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Abstract base class for executable version verification fixes. +/// +/// The logger instance. +public abstract class BaseExecutableVersionFix(ILogger logger) : BaseActionSet(logger) +{ + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(HasGame(installation)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (!HasGame(installation)) + { + return Task.FromResult(false); + } + + var exePath = FindExecutable(GetGamePath(installation)); + if (exePath == null) + { + return Task.FromResult(false); + } + + var versionInfo = FileVersionInfo.GetVersionInfo(exePath); + var version = versionInfo.FileVersion; + + if (version != null && VersionPrefixes.Any(p => version.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error checking {Game} executable version", GameDisplayName); + return Task.FromResult(false); + } + } + + /// + /// Gets the display name of the target game. + /// + protected abstract string GameDisplayName { get; } + + /// + /// Gets the expected version string display. + /// + protected abstract string TargetVersionDisplay { get; } + + /// + /// Gets the list of valid version prefixes for this game executable. + /// + protected abstract IReadOnlyList VersionPrefixes { get; } + + /// + /// Gets candidate executable file names to locate in the game directory. + /// + protected abstract IReadOnlyList CandidateExecutableNames { get; } + + /// + /// Checks whether the game installation contains the targeted game. + /// + /// The targeted game installation. + /// true if present; otherwise, false. + protected abstract bool HasGame(GameInstallation installation); + + /// + /// Gets the path to the game directory. + /// + /// The targeted game installation. + /// The game directory path. + protected abstract string? GetGamePath(GameInstallation installation); + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + if (!HasGame(installation)) + { + details.Add($"✗ {GameDisplayName} is not installed"); + return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} is not installed in this installation.", details)); + } + + details.Add($"{GameDisplayName} Executable Fix - Informational"); + details.Add(string.Empty); + details.Add($"This fix ensures the {GameDisplayName} {TargetVersionDisplay} patch is applied."); + details.Add(string.Empty); + + var gamePath = GetGamePath(installation); + var exePath = FindExecutable(gamePath); + + if (exePath != null) + { + var versionInfo = FileVersionInfo.GetVersionInfo(exePath); + var version = versionInfo.FileVersion; + + details.Add($"Current executable: {Path.GetFileName(exePath)}"); + details.Add($"Current version: {version ?? "unknown"}"); + + if (version != null && VersionPrefixes.Any(p => version.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + { + details.Add($"✓ {GameDisplayName} {TargetVersionDisplay} patch is already applied"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + details.Add($"⚠ {GameDisplayName} {TargetVersionDisplay} patch needs to be applied"); + details.Add(" Please use the appropriate patch in GenHub to update your game client."); + return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} executable is not version {TargetVersionDisplay}.", details)); + } + + details.Add($"⚠ {GameDisplayName} executable not found in: {gamePath}"); + return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} executable not found in {gamePath}", details)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error checking {Game} executable version", GameDisplayName); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + Logger.LogWarning("Undoing {Game} Executable Fix is not supported.", GameDisplayName); + return Task.FromResult(new ActionSetResult(true)); + } + + /// + /// Finds the first matching executable path in the specified game directory. + /// + /// The game directory path. + /// The path to the located executable, or null if not found. + protected string? FindExecutable(string? gamePath) + { + if (string.IsNullOrEmpty(gamePath) || !Directory.Exists(gamePath)) + { + return null; + } + + return CandidateExecutableNames + .Select(exe => Path.Combine(gamePath, exe)) + .FirstOrDefault(File.Exists); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs new file mode 100644 index 000000000..f61f8241d --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs @@ -0,0 +1,196 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Abstract base class for fixes that disable problematic DLLs/files by renaming them to a backup extension. +/// +public abstract class BaseFileRenameFix( + ILogger logger, + string targetFileName, + string backupFileName) + : BaseActionSet(logger) +{ + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && File.Exists(Path.Combine(installation.GeneralsPath, targetFileName))) + { + return Task.FromResult(true); + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath) && File.Exists(Path.Combine(installation.ZeroHourPath, targetFileName))) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + bool generalsApplied = !installation.HasGenerals || + string.IsNullOrEmpty(installation.GeneralsPath) || + !File.Exists(Path.Combine(installation.GeneralsPath, targetFileName)); + + bool zeroHourApplied = !installation.HasZeroHour || + string.IsNullOrEmpty(installation.ZeroHourPath) || + !File.Exists(Path.Combine(installation.ZeroHourPath, targetFileName)); + + return Task.FromResult(generalsApplied && zeroHourApplied); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add($"Starting {Title}..."); + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + if (!RenameFile(installation.GeneralsPath, details)) + { + details.Add($" ⚠ {targetFileName} not found (may already be fixed)"); + } + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + if (!RenameFile(installation.ZeroHourPath, details)) + { + details.Add($" ⚠ {targetFileName} not found (may already be fixed)"); + } + } + + details.Add($"✓ {Title} completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error applying {Title}", Title); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add($"Restoring {targetFileName}..."); + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + if (!RestoreFile(installation.GeneralsPath, details)) + { + details.Add($" ⚠ {backupFileName} not found (nothing to restore)"); + } + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + if (!RestoreFile(installation.ZeroHourPath, details)) + { + details.Add($" ⚠ {backupFileName} not found (nothing to restore)"); + } + } + + details.Add($"✓ {targetFileName} restoration completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error restoring {TargetFileName}", targetFileName); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private bool RenameFile(string directory, List details) + { + var originalPath = Path.Combine(directory, targetFileName); + var backupPath = Path.Combine(directory, backupFileName); + + if (!File.Exists(originalPath)) + { + return false; + } + + try + { + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + File.Move(originalPath, backupPath); + details.Add($" ✓ Renamed: {targetFileName} -> {backupFileName}"); + Logger.LogInformation("Renamed {OriginalPath} to {BackupPath}", originalPath, backupPath); + return true; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to rename {OriginalPath}", originalPath); + details.Add($" ✗ Error renaming {targetFileName}: {ex.Message}"); + return false; + } + } + + private bool RestoreFile(string directory, List details) + { + var originalPath = Path.Combine(directory, targetFileName); + var backupPath = Path.Combine(directory, backupFileName); + + if (!File.Exists(backupPath)) + { + return false; + } + + try + { + if (File.Exists(originalPath)) + { + File.Delete(originalPath); + } + + File.Move(backupPath, originalPath); + details.Add($" ✓ Restored: {backupFileName} -> {targetFileName}"); + Logger.LogInformation("Restored {BackupPath} to {OriginalPath}", backupPath, originalPath); + return true; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to restore {BackupPath}", backupPath); + details.Add($" ✗ Error restoring {backupFileName}: {ex.Message}"); + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs new file mode 100644 index 000000000..06b3f9113 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs @@ -0,0 +1,833 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Utilities; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +/// +/// Abstract base class for downloadable package deployment fixes (e.g., HD Icons, Expanded LAN Lobby). +/// Handles package download, hash validation, safe materialization with backup tracking, marker persistence, and rollback. +/// +public abstract class BasePackageDeploymentFix( + IHttpClientFactory httpClientFactory, + ILogger logger, + string defaultMarkerFileName, + string? markerPath = null) + : BaseActionSet(logger) +{ + /// + /// Execution context for package deployment operations. + /// + /// The temporary directory for archive extraction. + /// The persistent directory for backing up pre-existing game files. + /// The list tracking backup metadata for rollback and undo. + /// The list accumulating deployed file paths. + /// The diagnostic details list. + public record DeploymentContext( + string TempExtractDir, + string BackupDir, + List<(string DestPath, bool ExistedBefore, string? BackupPath)> BackupEntries, + List DeployedFiles, + List Details); + + /// + /// Gets the list of download URLs for the package. + /// + protected abstract IReadOnlyList DownloadUrls { get; } + + /// + /// Gets the expected SHA-256 hash for package verification. + /// + protected abstract string ExpectedSha256 { get; } + + /// + /// Gets the human-readable package name for logs and messages. + /// + protected abstract string PackageDisplayName { get; } + + /// + /// Gets the file prefix used for temporary download files. + /// + protected abstract string TempFilePrefix { get; } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(AreAssetsPresent(installation)); + } + + /// + /// Deploys a file with backup tracking, preventing duplicate backups of the same destination path. + /// + /// The path of the source file to deploy. + /// The destination path in the game directory. + /// The deployment context. + protected static void DeployFileWithBackup( + string sourceFilePath, + string destPath, + DeploymentContext context) + { + var existingEntryIndex = context.BackupEntries.FindIndex(b => string.Equals(b.DestPath, destPath, StringComparison.OrdinalIgnoreCase)); + if (existingEntryIndex >= 0) + { + // Already backed up during this deployment batch; overwrite destination with new file without destroying original backup + File.Copy(sourceFilePath, destPath, overwrite: true); + return; + } + + var existedBefore = File.Exists(destPath); + string? backupPath = null; + + if (existedBefore) + { + Directory.CreateDirectory(context.BackupDir); + backupPath = Path.Combine(context.BackupDir, $"{Guid.NewGuid():N}_{Path.GetFileName(destPath)}"); + File.Copy(destPath, backupPath, overwrite: true); + } + + context.BackupEntries.Add((destPath, existedBefore, backupPath)); + + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Copy(sourceFilePath, destPath, overwrite: true); + if (!context.DeployedFiles.Contains(destPath, StringComparer.OrdinalIgnoreCase)) + { + context.DeployedFiles.Add(destPath); + } + } + + /// + /// Collects existing file paths from a directory matching candidate names. + /// + /// The base directory path. + /// The candidate file names. + /// The list accumulating found paths. + protected static void CollectExistingFiles(string? basePath, IReadOnlyList candidateNames, List output) + { + if (string.IsNullOrEmpty(basePath) || !Directory.Exists(basePath)) + { + return; + } + + output.AddRange(candidateNames + .Select(name => Path.Combine(basePath, name)) + .Where(File.Exists) + .Except(output, StringComparer.OrdinalIgnoreCase)); + } + + /// + /// Extracts all non-directory archive entries to the destination directory. + /// + /// The archive to extract. + /// The destination extraction directory. + /// Cancellation token. + /// A dictionary mapping file name to extracted file path. + protected static async Task> ExtractArchiveEntriesAsync( + IArchive archive, + string extractDir, + CancellationToken ct) + { + var extractedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + long expandedBytes = 0; + + foreach (var entry in archive.Entries.Where(e => !e.IsDirectory && e.Key != null)) + { + ct.ThrowIfCancellationRequested(); + var fileName = Path.GetFileName(entry.Key); + if (string.IsNullOrEmpty(fileName)) + { + continue; + } + + var extractedFilePath = Path.Combine(extractDir, fileName); + await using var entryStream = await entry.OpenEntryStreamAsync(ct); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + extractedFilePath, + fileName, + ActionSetConstants.Validation.MaximumAddonPackageSizeBytes, + ActionSetConstants.Validation.MaximumAddonPackageSizeBytes - expandedBytes, + overwrite: true, + cancellationToken: ct); + + extractedFiles[fileName] = extractedFilePath; + } + + return extractedFiles; + } + + /// + /// Gets the resolved marker path for a specific game installation. + /// + /// The game installation. + /// The absolute marker file path. + protected string GetMarkerPath(GameInstallation installation) + { + if (!string.IsNullOrEmpty(markerPath)) + { + return markerPath; + } + + var baseDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "GenHub", + ActionSetConstants.Paths.SubActionSetMarkers); + + var key = ComputeInstallationKey(installation); + var scopedMarker = Path.Combine(baseDir, $"{Path.GetFileNameWithoutExtension(defaultMarkerFileName)}_{key}{Path.GetExtension(defaultMarkerFileName)}"); + + // Backward compatibility: migrate legacy global marker to scoped marker if scoped marker is missing + var globalMarker = Path.Combine(baseDir, defaultMarkerFileName); + if (!File.Exists(scopedMarker) && File.Exists(globalMarker)) + { + try + { + var markerDir = Path.GetDirectoryName(scopedMarker); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + File.Move(globalMarker, scopedMarker); + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to migrate legacy global marker {GlobalMarker} to scoped marker {ScopedMarker}", globalMarker, scopedMarker); + return globalMarker; + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied migrating legacy global marker {GlobalMarker} to scoped marker {ScopedMarker}", globalMarker, scopedMarker); + return globalMarker; + } + } + + return scopedMarker; + } + + /// + /// Gets the persistent backup directory for saving overwritten files. + /// + /// The game installation. + /// The backup directory path. + protected string GetBackupDirectory(GameInstallation installation) + { + var key = ComputeInstallationKey(installation); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "GenHub", + "Backups", + $"{Id}_{key}"); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var targetMarkerPath = GetMarkerPath(installation); + var persistentBackupDir = GetBackupDirectory(installation); + var tempFile = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_{Guid.NewGuid():N}.dat"); + var tempExtractDir = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_extract_{Guid.NewGuid():N}"); + var backupEntries = new List<(string DestPath, bool ExistedBefore, string? BackupPath)>(); + var deployedFiles = new List(); + var details = new List(); + var context = new DeploymentContext(tempExtractDir, persistentBackupDir, backupEntries, deployedFiles, details); + + try + { + details.Add($"Downloading {PackageDisplayName} package..."); + + var downloaded = await DownloadPackageAsync(tempFile, details, ct); + if (!downloaded) + { + return new ActionSetResult(false, $"Failed to download {PackageDisplayName} from available sources.", details); + } + + var validation = await DownloadSecurityValidator.ValidateFileAsync( + tempFile, + allowedSha256Hashes: [ExpectedSha256], + ct: ct); + + if (!validation.Success) + { + var errorSummary = string.Join("; ", validation.Errors); + Logger.LogWarning("Security validation failed for {Name} package: {Error}", PackageDisplayName, errorSummary); + return new ActionSetResult(false, $"Package failed security verification: {errorSummary}", details); + } + + details.Add("✓ Package integrity verified via SHA-256 checksum."); + details.Add($"Extracting {PackageDisplayName} assets..."); + Directory.CreateDirectory(tempExtractDir); + + var (extractedCount, deployed) = await ExtractAndDeployAssetsAsync( + tempFile, + context, + installation, + ct); + + if (deployed == null) + { + RollbackDeployment(backupEntries, persistentBackupDir, details); + return new ActionSetResult(false, $"Failed to extract and validate {PackageDisplayName} package.", details); + } + + details.Add($"✓ Extracted and deployed {extractedCount} assets to game folders."); + + if (!RecordDeploymentMarker(targetMarkerPath, backupEntries)) + { + details.Add("✗ Failed to record the deployment marker. Rolling back deployed files."); + RollbackDeployment(backupEntries, persistentBackupDir, details); + return new ActionSetResult(false, $"Failed to record the deployment marker for {Id}.", details); + } + + return new ActionSetResult(true, null, details); + } + catch (OperationCanceledException) + { + RollbackDeployment(backupEntries, persistentBackupDir, details); + throw; + } + catch (Exception ex) + { + RollbackDeployment(backupEntries, persistentBackupDir, details); + Logger.LogError(ex, "Error applying {Name} fix", PackageDisplayName); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + DeleteFileSafely(tempFile); + DeleteDirectorySafely(tempExtractDir); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var targetMarkerPath = GetMarkerPath(installation); + var persistentBackupDir = GetBackupDirectory(installation); + + try + { + if (!File.Exists(targetMarkerPath)) + { + if (AreAssetsPresent(installation)) + { + details.Add($"⚠ No deployment marker found. Custom {PackageDisplayName} files may have been installed manually; please remove them manually if desired."); + return Task.FromResult(new ActionSetResult(false, "No deployment marker found to undo.", details)); + } + + return Task.FromResult(new ActionSetResult(true, null, ["No deployment record found to undo."])); + } + + var lines = ReadMarkerLinesSafely(targetMarkerPath); + if (lines == null) + { + Logger.LogWarning("Failed to read installed file paths from marker {MarkerPath}", targetMarkerPath); + return Task.FromResult(new ActionSetResult(false, "Failed to read deployment marker", ["✗ Could not read deployment marker."])); + } + + if (lines.Length == 0) + { + DeleteFileSafely(targetMarkerPath); + DeleteDirectorySafely(persistentBackupDir); + return Task.FromResult(new ActionSetResult(true, null, ["No deployment record found to undo."])); + } + + var records = ParseMarkerRecords(lines, installation); + var (removedCount, restoredCount, restoredBackupPaths, remainingRecords) = RestoreOrDeleteRecordedFiles( + records, + installation, + persistentBackupDir, + ct); + + var markerUpdated = UpdateMarkerAfterUndo(targetMarkerPath, remainingRecords); + if (!markerUpdated) + { + details.Add("✗ Failed to update deployment marker after undo. Backups have been retained."); + return Task.FromResult(new ActionSetResult(false, "Failed to update deployment marker after undo.", details)); + } + + // Clean up restored backup files only after the marker update succeeded + var remainingBackups = remainingRecords + .Where(r => !string.IsNullOrEmpty(r.BackupPath)) + .Select(r => r.BackupPath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var backupPath in restoredBackupPaths.Where(b => !remainingBackups.Contains(b))) + { + DeleteFileSafely(backupPath); + } + + if (remainingRecords.Count == 0) + { + DeleteDirectorySafely(persistentBackupDir); + var summary = restoredCount > 0 + ? $"{PackageDisplayName} removed ({removedCount} files deleted, {restoredCount} originals restored)." + : $"{PackageDisplayName} removed ({removedCount} files deleted)."; + details.Add(summary); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + details.Add($"⚠ Partial undo: {removedCount} files removed, {restoredCount} restored, {remainingRecords.Count} files could not be processed."); + return Task.FromResult(new ActionSetResult(false, $"Failed to remove/restore {remainingRecords.Count} files during undo.", details)); + } + catch (IOException ex) + { + Logger.LogWarning(ex, "I/O error deleting marker or restoring files for {Name}", PackageDisplayName); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission error deleting marker or restoring files for {Name}", PackageDisplayName); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + /// Extracts archive contents and deploys them to target game directories with backup tracking. + /// + /// The local path of the downloaded archive. + /// The deployment context. + /// The targeted game installation. + /// The cancellation token. + /// A tuple of extracted file count and list of deployed file paths. + protected abstract Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync( + string archivePath, + DeploymentContext context, + GameInstallation installation, + CancellationToken ct); + + /// + /// Determines whether the deployed assets are present in the game installation. + /// + /// The game installation to inspect. + /// true if all required assets are present; otherwise, false. + protected abstract bool AreAssetsPresent(GameInstallation installation); + + /// + /// Gets legacy file paths if no absolute paths are present in marker. + /// + /// The game installation. + /// List of candidate legacy asset paths. + protected abstract List GetLegacyFilePaths(GameInstallation installation); + + /// + /// Downloads the package from available mirror URLs. + /// + /// The destination temporary file path. + /// The diagnostic details list. + /// The cancellation token. + /// true if download succeeded; otherwise, false. + protected async Task DownloadPackageAsync( + string tempFile, + List details, + CancellationToken ct) + { + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + foreach (var url in DownloadUrls) + { + try + { + Logger.LogInformation("Attempting {Name} download from {Url}", PackageDisplayName, url); + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await DownloadToFileAsync(response, tempFile, ct); + + var fileInfo = new FileInfo(tempFile); + if (fileInfo.Length < ActionSetConstants.Validation.MinimumAddonPackageSizeBytes) + { + Logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes).", url, fileInfo.Length); + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + + continue; + } + + details.Add($"✓ {PackageDisplayName} package downloaded successfully."); + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to download {Name} from {Url}", PackageDisplayName, url); + } + } + + return false; + } + + /// + /// Rolls back deployed assets and restores backed-up files upon deployment failure. + /// + /// The list of backup entries tracked during deployment. + /// The persistent backup directory path. + /// The diagnostic details list. + protected void RollbackDeployment( + List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries, + string backupDir, + List details) + { + details.Add("Rolling back deployed assets..."); + var hasRollbackError = false; + foreach (var (destPath, existedBefore, backupPath) in backupEntries) + { + if (!RollbackEntry(destPath, existedBefore, backupPath)) + { + hasRollbackError = true; + } + } + + if (!hasRollbackError) + { + CleanupEmptyBackupDirectory(backupDir); + details.Add("✓ Rollback completed."); + } + else + { + details.Add("⚠ Rollback completed with some file warnings. Backups have been retained for recovery."); + } + } + + private static async Task DownloadToFileAsync(HttpResponseMessage response, string tempFile, CancellationToken ct) + { + await using var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + await response.Content.CopyToAsync(fs, ct); + } + + private static string ComputeInstallationKey(GameInstallation installation) + { + if (string.IsNullOrEmpty(installation.InstallationPath)) + { + return "default"; + } + + var bytes = System.Text.Encoding.UTF8.GetBytes(installation.InstallationPath.ToUpperInvariant()); + return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes))[..12].ToLowerInvariant(); + } + + private static bool IsPathWithinDirectory(string filePath, string directoryPath) + { + if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(directoryPath)) + { + return false; + } + + try + { + var fullDir = Path.GetFullPath(directoryPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var fullFile = Path.GetFullPath(filePath); + return fullFile.StartsWith(fullDir, StringComparison.OrdinalIgnoreCase); + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + } + + private static bool IsValidDestinationPath(string destPath, GameInstallation installation) + { + if (string.IsNullOrWhiteSpace(destPath) || !Path.IsPathRooted(destPath)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(installation.InstallationPath) && + IsPathWithinDirectory(destPath, installation.InstallationPath)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(installation.GeneralsPath) && + IsPathWithinDirectory(destPath, installation.GeneralsPath)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(installation.ZeroHourPath) && + IsPathWithinDirectory(destPath, installation.ZeroHourPath)) + { + return true; + } + + return false; + } + + private List<(string DestPath, string? BackupPath)> ParseMarkerRecords(string[] lines, GameInstallation installation) + { + var records = new List<(string DestPath, string? BackupPath)>(); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var parts = line.Split('|'); + var dest = parts[0].Trim(); + var backup = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1].Trim() : null; + if (!string.IsNullOrEmpty(dest)) + { + records.Add((dest, backup)); + } + } + + var hasRootedPaths = records.Any(r => Path.IsPathRooted(r.DestPath)); + if (!hasRootedPaths) + { + var legacyPaths = GetLegacyFilePaths(installation); + records = legacyPaths.Select(p => (p, (string?)null)).ToList(); + } + + return records; + } + + private (int RemovedCount, int RestoredCount, List RestoredBackupPaths, List<(string DestPath, string? BackupPath)> RemainingRecords) RestoreOrDeleteRecordedFiles( + IEnumerable<(string DestPath, string? BackupPath)> records, + GameInstallation installation, + string persistentBackupDir, + CancellationToken ct) + { + var removedCount = 0; + var restoredCount = 0; + var restoredBackupPaths = new List(); + var remainingRecords = new List<(string DestPath, string? BackupPath)>(); + + foreach (var (destPath, backupPath) in records) + { + ct.ThrowIfCancellationRequested(); + var trimmedDest = destPath.Trim(); + if (!IsValidDestinationPath(trimmedDest, installation)) + { + Logger.LogWarning("Skipping recorded destination {FilePath} as it is outside the installation directory", trimmedDest); + remainingRecords.Add((trimmedDest, backupPath)); + continue; + } + + if (!string.IsNullOrEmpty(backupPath) && !IsPathWithinDirectory(backupPath, persistentBackupDir)) + { + Logger.LogWarning("Skipping recorded backup {BackupPath} as it is outside the backup directory", backupPath); + remainingRecords.Add((trimmedDest, backupPath)); + continue; + } + + try + { + if (!string.IsNullOrEmpty(backupPath)) + { + if (TryRestoreBackup(trimmedDest, backupPath)) + { + restoredBackupPaths.Add(backupPath); + restoredCount++; + } + else + { + remainingRecords.Add((trimmedDest, backupPath)); + } + } + else if (File.Exists(trimmedDest)) + { + DeleteFileSafely(trimmedDest); + if (File.Exists(trimmedDest)) + { + remainingRecords.Add((trimmedDest, backupPath)); + } + else + { + removedCount++; + } + } + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to restore or delete file {FilePath} during undo", trimmedDest); + remainingRecords.Add((trimmedDest, backupPath)); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied restoring or deleting file {FilePath} during undo", trimmedDest); + remainingRecords.Add((trimmedDest, backupPath)); + } + } + + return (removedCount, restoredCount, restoredBackupPaths, remainingRecords); + } + + private bool TryRestoreBackup(string destPath, string backupPath) + { + if (!File.Exists(backupPath)) + { + Logger.LogWarning("Recorded backup missing for {FilePath} during undo; retaining destination to prevent data loss.", destPath); + return false; + } + + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Copy(backupPath, destPath, overwrite: true); + return true; + } + + private bool UpdateMarkerAfterUndo(string targetMarkerPath, IReadOnlyList<(string DestPath, string? BackupPath)> remainingRecords) + { + if (remainingRecords.Count == 0) + { + DeleteFileSafely(targetMarkerPath); + return !File.Exists(targetMarkerPath); + } + + string? tempMarker = null; + try + { + var markerDir = Path.GetDirectoryName(targetMarkerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + tempMarker = Path.Combine(markerDir ?? Path.GetTempPath(), $"{Guid.NewGuid():N}.tmp"); + var lines = remainingRecords.Select(r => $"{r.DestPath}|{r.BackupPath ?? string.Empty}"); + File.WriteAllLines(tempMarker, lines); + File.Move(tempMarker, targetMarkerPath, overwrite: true); + return true; + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to rewrite marker file {MarkerPath} with remaining files", targetMarkerPath); + DeleteFileSafely(tempMarker); + return false; + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied rewriting marker file {MarkerPath} with remaining files", targetMarkerPath); + DeleteFileSafely(tempMarker); + return false; + } + } + + private bool RecordDeploymentMarker( + string targetMarkerPath, + List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries) + { + string? tempMarker = null; + try + { + var markerDir = Path.GetDirectoryName(targetMarkerPath); + if (!string.IsNullOrEmpty(markerDir)) + { + Directory.CreateDirectory(markerDir); + } + + tempMarker = Path.Combine(markerDir ?? Path.GetTempPath(), $"{Guid.NewGuid():N}.tmp"); + var lines = backupEntries.Select(b => $"{b.DestPath}|{b.BackupPath ?? string.Empty}"); + File.WriteAllLines(tempMarker, lines); + File.Move(tempMarker, targetMarkerPath, overwrite: true); + return true; + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to create marker file for {Name}", PackageDisplayName); + DeleteFileSafely(tempMarker); + return false; + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied creating marker file for {Name}", PackageDisplayName); + DeleteFileSafely(tempMarker); + return false; + } + } + + private bool RollbackEntry(string destPath, bool existedBefore, string? backupPath) + { + try + { + if (existedBefore) + { + if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath)) + { + File.Copy(backupPath, destPath, overwrite: true); + DeleteFileSafely(backupPath); + return true; + } + + Logger.LogWarning("Original backup missing for {DestPath} during rollback", destPath); + return false; + } + + if (File.Exists(destPath)) + { + DeleteFileSafely(destPath); + if (File.Exists(destPath)) + { + return false; + } + } + + return true; + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to restore or remove file during rollback: {Path}", destPath); + return false; + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied restoring or removing file during rollback: {Path}", destPath); + return false; + } + } + + private void CleanupEmptyBackupDirectory(string backupDir) + { + try + { + if (Directory.Exists(backupDir) && !Directory.EnumerateFileSystemEntries(backupDir).Any()) + { + DeleteDirectorySafely(backupDir); + } + } + catch (IOException ex) + { + Logger.LogWarning(ex, "Failed to inspect or delete empty backup directory {BackupDir} during rollback", backupDir); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogWarning(ex, "Permission denied inspecting or deleting empty backup directory {BackupDir} during rollback", backupDir); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs new file mode 100644 index 000000000..d15d76af8 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs @@ -0,0 +1,298 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Security; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Abstract base class for Visual C++ Redistributable fixes. +/// Manages secure download, digital signature verification, silent execution, and cleanup. +/// +public abstract class BaseVCRedistFix( + IHttpClientFactory httpClientFactory, + ILogger logger) + : BaseActionSet(logger) +{ + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + /// Gets the list of download URLs for the redistributable installer. + /// + protected abstract IReadOnlyList DownloadUrls { get; } + + /// + /// Gets the arguments to pass to the installer for silent installation. + /// + protected abstract string InstallerArguments { get; } + + /// + /// Gets the human-readable display name of the redistributable. + /// + protected abstract string RedistDisplayName { get; } + + /// + /// Gets the temporary file prefix for downloads. + /// + protected abstract string TempFilePrefix { get; } + + /// + /// Gets the minimum expected file size in bytes for the installer. + /// + protected virtual long MinimumFileSizeBytes => ActionSetConstants.Validation.MinimumAddonPackageSizeBytes; + + /// + /// Gets the optional collection of pinned SHA-256 hashes. + /// + protected virtual IReadOnlyList? AllowedSha256Hashes => null; + + /// + /// Gets the expected Authenticode publisher substring. + /// + protected virtual string ExpectedPublisher => ActionSetConstants.Security.MicrosoftPublisher; + + /// + /// Checks whether an MSI product code is installed in either 32-bit or 64-bit registry views. + /// + /// The MSI product GUID. + /// True if installed; otherwise false. + protected bool IsProductInstalled(string productCode) + { + try + { + var uninstallKeyPath = RegistryConstants.UninstallKeyPath; + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var uninstallKey = baseKey.OpenSubKey(uninstallKeyPath); + if (uninstallKey != null) + { + using var subKey = uninstallKey.OpenSubKey(productCode); + if (subKey != null) + { + return true; + } + } + + using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var uninstallKey64 = baseKey64.OpenSubKey(uninstallKeyPath); + if (uninstallKey64 != null) + { + using var subKey64 = uninstallKey64.OpenSubKey(productCode); + if (subKey64 != null) + { + return true; + } + } + } + catch (SecurityException ex) + { + Logger.LogDebug(ex, "Security exception inspecting registry for {ProductCode}", productCode); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogDebug(ex, "Unauthorized access inspecting registry for {ProductCode}", productCode); + } + catch (IOException ex) + { + Logger.LogDebug(ex, "I/O error inspecting registry for {ProductCode}", productCode); + } + catch (ArgumentException ex) + { + Logger.LogDebug(ex, "Argument exception inspecting registry for {ProductCode}", productCode); + } + catch (ObjectDisposedException ex) + { + Logger.LogDebug(ex, "Registry key disposed inspecting registry for {ProductCode}", productCode); + } + + return false; + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var tempFile = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_{Guid.NewGuid():N}.exe"); + var details = new List(); + FileStream? lockedStream = null; + + try + { + details.Add($"Downloading {RedistDisplayName}..."); + + var downloaded = await DownloadInstallerAsync(tempFile, ct); + if (!downloaded) + { + return new ActionSetResult(false, $"Failed to download {RedistDisplayName} from all available sources.", details); + } + + var fileInfo = new FileInfo(tempFile); + var fileSize = fileInfo.Length; + + var securityValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync( + tempFile, + allowedSha256Hashes: AllowedSha256Hashes, + expectedAuthenticodePublisher: ExpectedPublisher, + allowExpiredCertificates: true, + ct: ct); + + if (!securityValidation.Success || securityValidation.Data == null) + { + var errorSummary = string.Join("; ", securityValidation.Errors); + Logger.LogWarning("Security validation failed for {Name}: {Error}", RedistDisplayName, errorSummary); + DeleteFileSafely(tempFile); + return new ActionSetResult(false, $"Security validation failed: {errorSummary}", details); + } + + lockedStream = securityValidation.Data; + await lockedStream.DisposeAsync(); + lockedStream = null; + + details.Add($"✓ Downloaded and verified {fileSize / 1024.0 / 1024.0:F2} MB"); + details.Add($"Installing {RedistDisplayName} (silent mode)..."); + details.Add(" ⚠ This may require administrator privileges"); + Logger.LogInformation("Installing {Name}...", RedistDisplayName); + + var (success, exitCode, errorMsg) = await RunInstallerProcessAsync(tempFile, InstallerArguments, ct); + if (success) + { + details.Add($"✓ {RedistDisplayName} installed successfully (exit code: {exitCode})"); + return new ActionSetResult(true, null, details); + } + + details.Add($"✗ Installation failed with exit code: {exitCode}"); + return new ActionSetResult(false, errorMsg ?? $"Installation failed with exit code {exitCode}", details); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error installing {Name}", RedistDisplayName); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + if (lockedStream != null) + { + await lockedStream.DisposeAsync(); + } + + DeleteFileSafely(tempFile); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + return Task.FromResult(new ActionSetResult( + true, + null, + [$"ℹ {RedistDisplayName} is a shared system component and does not need to be uninstalled."])); + } + + private static async Task<(bool Success, int ExitCode, string? ErrorMessage)> RunInstallerProcessAsync( + string installerPath, + string arguments, + CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = installerPath, + Arguments = arguments, + UseShellExecute = true, + Verb = "runas", + CreateNoWindow = true, + }; + + Process? process; + try + { + process = Process.Start(psi); + if (process == null) + { + return (false, -1, "Failed to start installer process"); + } + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + return (false, 1223, "Installation declined: administrator approval was not granted."); + } + catch (Win32Exception ex) + { + return (false, ex.NativeErrorCode, $"Failed to launch installer process: {ex.Message}"); + } + + using (process) + { + await process.WaitForExitAsync(ct); + var exitCode = process.ExitCode; + + if (exitCode is ProcessConstants.ExitCodeSuccess or ProcessConstants.ExitCodeRebootRequired) + { + return (true, exitCode, null); + } + + return (false, exitCode, $"Installer returned non-zero exit code: {exitCode}"); + } + } + + private async Task DownloadInstallerAsync(string tempFile, CancellationToken ct) + { + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + foreach (var url in DownloadUrls) + { + try + { + Logger.LogInformation("Attempting download from {Url}", url); + using var response = await client.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + await using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true)) + { + await response.Content.CopyToAsync(fs, ct); + } + + var fileInfo = new FileInfo(tempFile); + if (fileInfo.Length < MinimumFileSizeBytes) + { + Logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes)", url, fileInfo.Length); + DeleteFileSafely(tempFile); + continue; + } + + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Download failed from {Url}", url); + } + } + + return false; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs new file mode 100644 index 000000000..dc029b8bd --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs @@ -0,0 +1,23 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using GenHub.Core.Constants; +using Microsoft.Extensions.Logging; + +/// +/// Fix for the BrowserEngine.dll which causes crashes on modern systems. +/// +public class BrowserEngineFix(ILogger logger) + : BaseFileRenameFix(logger, GameClientConstants.BrowserEngineDll, GameClientConstants.BrowserEngineDllBak) +{ + /// + public override string Id => "BrowserEngineFix"; + + /// + public override string Title => "Browser Engine DLL Fix"; + + /// + public override string Description => "Disables the obsolete BrowserEngine.dll that causes instant crashes during game startup on modern Windows."; + + /// + public override string DetailedDescription => "Generals originally bundled an embedded web browser DLL from 2002 to display EA in-game news. On modern Windows, this outdated library triggers memory access violations that crash the game before reaching the main menu. This fix renames BrowserEngine.dll to safely bypass the crash."; +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs new file mode 100644 index 000000000..c813b8cce --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs @@ -0,0 +1,239 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Fix that creates registry entries for C&C Online (Revora) multiplayer service. +/// This enables the game to properly detect and connect to C&C Online servers. +/// +public class CncOnlineLauncherFix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "CncOnlineLauncherFix"; + + /// + public override string Title => "C&C Online Launcher Fix"; + + /// + public override string Description => "Configures Revora C&C:Online registry keys so community multiplayer services can detect and launch your game."; + + /// + public override string DetailedDescription => "Since EA GameSpy servers were decommissioned, C&C:Online provides the primary multiplayer network for Generals and Zero Hour. This fix writes the necessary installation path and version metadata into the registry so community launcher hooks can direct multiplayer traffic to active community servers."; + + /// + public override string Category => ActionSetConstants.Categories.Multiplayer; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (installation.HasGenerals) + { + var genInstalled = registryService.GetStringValue( + RegistryConstants.CncOnlineGeneralsKeyPath, + RegistryConstants.InstallPathValueName, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + if (string.IsNullOrEmpty(genInstalled)) + { + return Task.FromResult(false); + } + } + + if (installation.HasZeroHour) + { + var zhInstalled = registryService.GetStringValue( + RegistryConstants.CncOnlineZeroHourKeyPath, + RegistryConstants.InstallPathValueName, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + if (string.IsNullOrEmpty(zhInstalled)) + { + return Task.FromResult(false); + } + } + + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking C&C Online registry status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting C&C Online registry configuration..."); + bool allSucceeded = true; + + if (installation.HasGenerals) + { + allSucceeded &= ConfigureGameEntry( + "Generals", + RegistryConstants.CncOnlineGeneralsKeyPath, + installation.GeneralsPath, + RegistryConstants.CncOnlineGeneralsVersion, + details); + } + + if (installation.HasZeroHour) + { + allSucceeded &= ConfigureGameEntry( + "Zero Hour", + RegistryConstants.CncOnlineZeroHourKeyPath, + installation.ZeroHourPath, + RegistryConstants.CncOnlineZeroHourVersion, + details); + } + + var basePath = installation.HasGenerals ? installation.GeneralsPath : installation.ZeroHourPath; + if (!string.IsNullOrEmpty(basePath)) + { + allSucceeded &= ConfigureMainEntry(basePath, details); + } + + if (!allSucceeded) + { + return Task.FromResult(new ActionSetResult(false, "Failed to write one or more C&C Online registry entries.", details)); + } + + details.Add("✓ C&C Online registry configuration completed successfully"); + logger.LogInformation("C&C Online registry fix applied with {DetailCount} actions", details.Count); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying C&C Online registry fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Removing C&C Online registry entries..."); + + if (installation.HasGenerals) + { + registryService.DeleteValue(RegistryConstants.CncOnlineGeneralsKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + registryService.DeleteValue(RegistryConstants.CncOnlineGeneralsKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineGeneralsKeyPath}"); + } + + if (installation.HasZeroHour) + { + registryService.DeleteValue(RegistryConstants.CncOnlineZeroHourKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + registryService.DeleteValue(RegistryConstants.CncOnlineZeroHourKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineZeroHourKeyPath}"); + } + + registryService.DeleteValue(RegistryConstants.CncOnlineKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + registryService.DeleteValue(RegistryConstants.CncOnlineKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser); + details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineKeyPath}"); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing C&C Online registry fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private bool ConfigureGameEntry( + string gameName, + string keyPath, + string installPath, + string version, + List details) + { + details.Add($"Configuring C&C Online for {gameName} at: {installPath}"); + + bool ok1 = registryService.SetStringValue( + keyPath, + RegistryConstants.InstallPathValueName, + installPath, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + bool ok2 = registryService.SetStringValue( + keyPath, + RegistryConstants.VersionValueName, + version, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + if (ok1 && ok2) + { + details.Add($"✓ Created: HKCU\\{keyPath}"); + details.Add($" • InstallPath = {installPath}"); + details.Add($" • Version = {version}"); + logger.LogInformation("Created C&C Online registry entries for {GameName}", gameName); + return true; + } + + details.Add($"✗ Failed to write C&C Online registry entries for {gameName}"); + return false; + } + + private bool ConfigureMainEntry(string basePath, List details) + { + details.Add("Creating main C&C Online registry entry..."); + + bool ok1 = registryService.SetStringValue( + RegistryConstants.CncOnlineKeyPath, + RegistryConstants.InstallPathValueName, + basePath, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + bool ok2 = registryService.SetStringValue( + RegistryConstants.CncOnlineKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.CncOnlineVersion, + useWow6432Node: true, + hive: RegistryHive.CurrentUser); + + if (ok1 && ok2) + { + details.Add($"✓ Created: HKCU\\{RegistryConstants.CncOnlineKeyPath}"); + details.Add($" • InstallPath = {basePath}"); + details.Add($" • Version = {RegistryConstants.CncOnlineVersion}"); + return true; + } + + details.Add("✗ Failed to write main C&C Online registry entries"); + return false; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs new file mode 100644 index 000000000..be1a2aa3d --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs @@ -0,0 +1,148 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that checks for DirectX 8 DLLs required by the game. +/// This fix verifies that necessary DirectX 8 runtime files are present +/// and provides guidance if they are missing. +/// +public class D3D8XdllCheck(ILogger logger) : BaseActionSet(logger) +{ + // DirectX 8/9 DLLs that Generals and Zero Hour may require (Retail only) + private static readonly IReadOnlyList RequiredDLLs = + [ + "d3d8.dll", + "d3d8thk.dll", + "d3dx9_43.dll", + ]; + + /// + public override string Id => "D3D8XDLLCheck"; + + /// + public override string Title => "DirectX 8 DLL Check"; + + /// + public override string Description => "Scans system directories for legacy DirectX 8/9 runtime DLLs (d3d8.dll, d3dx9_43.dll) required to launch the game."; + + /// + public override string DetailedDescription => "Modern Windows systems do not pre-install legacy DirectX 8 and 9 runtime libraries by default. This diagnostic check verifies whether essential graphics binaries (d3d8.dll, d3d8thk.dll, and d3dx9_43.dll) exist in SysWOW64 or the game directory to prevent missing DLL startup errors."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var missingDLLs = GetMissingDlls(installation); + var allPresent = missingDLLs.Count == 0; + + if (allPresent) + { + logger.LogInformation("All required DirectX 8 DLLs are present"); + } + else + { + logger.LogWarning("Missing DirectX 8 DLLs: {DLLs}", string.Join(", ", missingDLLs)); + } + + return Task.FromResult(allPresent); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking DirectX 8 DLLs"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var missingDLLs = GetMissingDlls(installation); + + if (missingDLLs.Count == 0) + { + logger.LogInformation("All required DirectX 8 DLLs are present. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + logger.LogWarning("The following DirectX 8 DLLs are missing: {Dlls}. Please run DirectXRuntimeFix.", string.Join(", ", missingDLLs)); + + return Task.FromResult(new ActionSetResult(false, $"Missing {missingDLLs.Count} DirectX 8 DLL(s). Please run DirectX Runtime Fix to install required runtime libraries.", [$"Missing {missingDLLs.Count} DirectX 8 DLL(s). Please run DirectX Runtime Fix."])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking DirectX 8 DLLs"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogWarning("D3D8XDLLCheck is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static IReadOnlyList GetMissingDlls(GameInstallation installation) + { + var system32 = Environment.GetFolderPath(Environment.SpecialFolder.System); + var sysWow64 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "SysWOW64"); + + var checkPaths = new List(); + if (!string.IsNullOrEmpty(installation.InstallationPath)) + { + checkPaths.Add(installation.InstallationPath); + } + + if (!string.IsNullOrEmpty(installation.GeneralsPath)) + { + checkPaths.Add(installation.GeneralsPath); + } + + if (!string.IsNullOrEmpty(installation.ZeroHourPath)) + { + checkPaths.Add(installation.ZeroHourPath); + } + + var missing = new List(); + foreach (var dll in RequiredDLLs) + { + var inSystem32 = File.Exists(Path.Combine(system32, dll)); + var inSysWow64 = File.Exists(Path.Combine(sysWow64, dll)); + var inGameDir = checkPaths.Exists(p => File.Exists(Path.Combine(p, dll))); + + if (!inSystem32 && !inSysWow64 && !inGameDir) + { + missing.Add(dll); + } + } + + return missing; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DbgHelpFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DbgHelpFix.cs new file mode 100644 index 000000000..a0188c87c --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DbgHelpFix.cs @@ -0,0 +1,23 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using GenHub.Core.Constants; +using Microsoft.Extensions.Logging; + +/// +/// Fix for the dbghelp.dll which causes crashes on modern systems. +/// +public class DbgHelpFix(ILogger logger) + : BaseFileRenameFix(logger, GameClientConstants.DbgHelpDll, GameClientConstants.DbgHelpDllBak) +{ + /// + public override string Id => "DbgHelpFix"; + + /// + public override string Title => "Debug Help DLL Fix"; + + /// + public override string Description => "Disables the outdated dbghelp.dll in the game folder so Windows uses the modern, stable system library."; + + /// + public override string DetailedDescription => "The legacy dbghelp.dll bundled inside 2003 game installations causes memory faults and random crash-to-desktop errors on modern Windows. Renaming this local DLL allows the game to safely fall back to the stable system version in SysWOW64."; +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DirectXRuntimeFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DirectXRuntimeFix.cs new file mode 100644 index 000000000..5db3ca2f7 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DirectXRuntimeFix.cs @@ -0,0 +1,329 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +/// +/// Fix that downloads and installs DirectX 8.1 and 9.0c runtime components required for Generals and Zero Hour. +/// +public class DirectXRuntimeFix(IHttpClientFactory httpClientFactory, ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "DirectXRuntimeFix"; + + /// + public override string Title => "DirectX 8.1 / 9.0c Runtime"; + + /// + public override string Description => "Installs legacy DirectX 8.1 and 9.0c 32-bit runtime libraries (also managed in Downloads)."; + + /// + public override string DetailedDescription => "Generals and Zero Hour require legacy DirectX 8.1/9.0c runtime components missing from modern Windows installations. This package downloads and installs the official DirectX redistributable, deploying required 32-bit graphics libraries (d3d8.dll, d3dx9_43.dll) into SysWOW64. You can also download and manage this runtime from the Downloads section."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; // Network failures shouldn't abort entire sequence + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // This fix is applicable regardless of installation type as it's a system dependency + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + // GenPatcher check: If D3DX9_43.dll (DX9) and d3d8.dll (DX8 Core) exist, we are good. + // Note: Modern dxwebsetup often skips d3dx8.dll (helper), but d3d8.dll is sufficient for the game to launch. + var sysWow64Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "SysWOW64"); + var dx9Dll = Path.Combine(sysWow64Path, "D3DX9_43.dll"); + var dx8Dll = Path.Combine(sysWow64Path, "d3d8.dll"); + + return Task.FromResult(File.Exists(dx9Dll) && File.Exists(dx8Dll)); + } + catch + { + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var tempFolder = Path.Combine(Path.GetTempPath(), $"GenHub_DirectX_{Guid.NewGuid():N}"); + var zipFile = Path.Combine(tempFolder, "dx_runtime.zip"); + var extractPath = Path.Combine(tempFolder, "Extracted"); + + try + { + details.Add("Starting DirectX Runtime installation..."); + Directory.CreateDirectory(extractPath); + details.Add($"Temp directory: {tempFolder}"); + details.Add("Downloading DirectX Runtime package..."); + + var downloadResult = await DownloadAndValidateAsync(tempFolder, zipFile, details, ct); + if (!downloadResult.Success || downloadResult.Data == default) + { + return new ActionSetResult(false, string.Join("; ", downloadResult.Errors), details); + } + + var (isExe, downloadPath) = downloadResult.Data; + string setupExe = string.Empty; + string arguments = string.Empty; + + if (isExe) + { + setupExe = downloadPath; + arguments = "/Q"; + details.Add("Running DirectX Web Setup..."); + } + else + { + var extractResult = ExtractPackage(zipFile, extractPath, details); + if (!extractResult.Success || string.IsNullOrEmpty(extractResult.Data)) + { + return new ActionSetResult(false, string.Join("; ", extractResult.Errors), details); + } + + setupExe = extractResult.Data; + arguments = "/silent"; + + var exeValidation = await DownloadSecurityValidator.ValidateFileAsync( + setupExe, + expectedAuthenticodePublisher: ActionSetConstants.Security.MicrosoftPublisher, + ct: ct); + + if (!exeValidation.Success) + { + var errorSummary = string.Join("; ", exeValidation.Errors); + logger.LogWarning("Security validation failed for extracted DirectX setup: {Error}", errorSummary); + return new ActionSetResult(false, $"Extracted DirectX setup failed security validation: {errorSummary}", details); + } + } + + return await RunSetupProcessAsync(setupExe, arguments, details, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Error implementing DirectX Runtime Fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + DeleteDirectorySafely(tempFolder); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogInformation("DirectX Runtime is a core Windows component and cannot be uninstalled automatically."); + return Task.FromResult(new ActionSetResult(false, "DirectX Runtime is a system component that cannot be automatically uninstalled.", ["DirectX runtime components remain installed on the system."])); + } + + private async Task> DownloadAndValidateAsync( + string tempFolder, + string zipFile, + List details, + CancellationToken ct) + { + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + client.Timeout = TimeSpan.FromMinutes(5); + + var urls = new[] + { + ExternalUrls.DirectXRuntimeDownloadUrlPrimary, + ExternalUrls.DirectXRuntimeDownloadUrlMirror1, + }; + + foreach (var url in urls) + { + var result = await TryDownloadMirrorAsync(client, url, tempFolder, zipFile, details, ct); + if (result.Success) + { + return result; + } + } + + return OperationResult<(bool IsExe, string DownloadPath)>.CreateFailure("Failed to download DirectX Runtime from all mirrors."); + } + + private async Task> TryDownloadMirrorAsync( + HttpClient client, + string url, + string tempFolder, + string zipFile, + List details, + CancellationToken ct) + { + var uri = new Uri(url); + var isExe = uri.AbsolutePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + var downloadPath = isExe + ? Path.Combine(tempFolder, $"dxwebsetup_{Guid.NewGuid():N}.exe") + : zipFile; + + try + { + logger.LogInformation("Attempting download from {Url}", url); + + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + logger.LogInformation("Streaming response content to disk at {Path} (Total size: {TotalBytes} bytes)...", downloadPath, totalBytes); + + await using (var fileStream = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await response.Content.CopyToAsync(fileStream, ct); + } + + var downloadedFileInfo = new FileInfo(downloadPath); + if (downloadedFileInfo.Length < ActionSetConstants.Validation.MinimumAddonPackageSizeBytes) + { + logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Likely blocked by proxy.", url, downloadedFileInfo.Length); + DeleteFileSafely(downloadPath); + return OperationResult<(bool IsExe, string DownloadPath)>.CreateFailure($"Downloaded file from {uri.Host} was incomplete or corrupted."); + } + + details.Add($"✓ Downloaded {downloadedFileInfo.Length / 1024.0 / 1024.0:F2} MB from {uri.Host}"); + + if (!isExe) + { + if (!ValidateZipArchive(downloadPath, url)) + { + DeleteFileSafely(downloadPath); + return OperationResult<(bool IsExe, string DownloadPath)>.CreateFailure($"Corrupted ZIP archive downloaded from {uri.Host}."); + } + } + else + { + var securityValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync( + downloadPath, + expectedAuthenticodePublisher: ActionSetConstants.Security.MicrosoftPublisher, + allowExpiredCertificates: true, + ct: ct); + + if (!securityValidation.Success || securityValidation.Data == null) + { + var errorSummary = string.Join("; ", securityValidation.Errors); + logger.LogWarning("Authenticode verification failed for DirectX web setup from {Url}: {Error}", url, errorSummary); + DeleteFileSafely(downloadPath); + return OperationResult<(bool IsExe, string DownloadPath)>.CreateFailure($"Security validation failed for installer from {uri.Host}: {errorSummary}"); + } + + await securityValidation.Data.DisposeAsync(); + } + + return OperationResult<(bool IsExe, string DownloadPath)>.CreateSuccess((isExe, downloadPath)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to download from {Url}: {Error}", url, ex.Message); + DeleteFileSafely(downloadPath); + return OperationResult<(bool IsExe, string DownloadPath)>.CreateFailure(ex.Message); + } + } + + private bool ValidateZipArchive(string downloadPath, string url) + { + try + { + using var archive = ZipFile.OpenRead(downloadPath); + var entryCount = archive.Entries.Count; + logger.LogInformation("Validated zip archive from {Url} ({Count} entries)", url, entryCount); + return entryCount > 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Downloaded file from {Url} is corrupt", url); + return false; + } + } + + private OperationResult ExtractPackage(string zipFile, string extractPath, List details) + { + details.Add("Extracting DirectX Runtime..."); + logger.LogInformation("Extracting DirectX Runtime..."); + ZipFile.ExtractToDirectory(zipFile, extractPath); + + var extractedFiles = Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories); + details.Add($"✓ Extracted {extractedFiles.Length} files"); + + var setupExe = Path.Combine(extractPath, ActionSetConstants.FileNames.DxSetupExe); + if (!File.Exists(setupExe)) + { + details.Add($"✗ {ActionSetConstants.FileNames.DxSetupExe} not found in package"); + return OperationResult.CreateFailure($"{ActionSetConstants.FileNames.DxSetupExe} not found in downloaded package."); + } + + return OperationResult.CreateSuccess(setupExe); + } + + private async Task RunSetupProcessAsync( + string setupExe, + string arguments, + List details, + CancellationToken ct) + { + details.Add("Running DirectX Setup (silent mode)..."); + details.Add(" ⚠ This may require administrator privileges"); + logger.LogInformation("Running DirectX Setup (Silent)..."); + + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = setupExe, + Arguments = arguments, + UseShellExecute = true, + Verb = "runas", + }); + + if (process == null) + { + details.Add("✗ Failed to start DirectX setup process"); + return new ActionSetResult(false, "Failed to start DirectX setup process.", details); + } + + await process.WaitForExitAsync(ct); + + if (process.ExitCode != ProcessConstants.ExitCodeSuccess && process.ExitCode != ProcessConstants.ExitCodeRebootRequired) + { + logger.LogError("DirectX setup failed with exit code {ExitCode}", process.ExitCode); + details.Add($"✗ DirectX setup failed with exit code {process.ExitCode}"); + return new ActionSetResult(false, $"DirectX setup exited with code {process.ExitCode}", details); + } + + if (process.ExitCode == ProcessConstants.ExitCodeRebootRequired) + { + details.Add("✓ DirectX setup completed successfully (reboot required)"); + } + else + { + details.Add("✓ DirectX setup completed successfully"); + } + + details.Add("✓ DirectX Runtime installation completed"); + return new ActionSetResult(true, null, details); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DisableOriginInGame.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DisableOriginInGame.cs new file mode 100644 index 000000000..daf676eff --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DisableOriginInGame.cs @@ -0,0 +1,170 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that disables Origin in-game overlay for Generals and Zero Hour. +/// The Origin overlay can cause performance issues and conflicts with the game. +/// +public class DisableOriginInGame(ILogger logger) : BaseActionSet(logger) +{ + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "DisableOriginInGame.done"); + + /// + public override string Id => "DisableOriginInGame"; + + /// + public override string Title => "Disable Origin In-Game Overlay"; + + /// + public override string Description => "Detects if the Origin in-game overlay is active and guides disabling it to prevent rendering conflicts and crashes."; + + /// + public override string DetailedDescription => "The legacy Origin overlay attempts to hook into the game's 32-bit DirectX 8 graphics pipeline, causing frame drops, mouse desync, and startup crashes. This fix checks your Origin configuration (Origin.ini) and provides instructions on disabling the overlay."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Origin is actually installed (something to disable) + var originInstalled = IsOriginInstalled(); + return Task.FromResult(originInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(IsOriginOverlayDisabled() || MarkerExists(_markerPath)); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var originInstalled = IsOriginInstalled(); + + if (!originInstalled) + { + logger.LogInformation("Origin is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true, null, ["Origin is not installed. No action needed."])); + } + + if (IsOriginOverlayDisabled()) + { + logger.LogInformation("Origin in-game overlay is already disabled."); + WriteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Origin in-game overlay is already disabled."])); + } + + logger.LogWarning("Origin in-game overlay is enabled. Please disable it in Origin Application Settings > Origin In-Game."); + + WriteMarkerFile(_markerPath); + + return Task.FromResult(new ActionSetResult(true, null, [ + "Please manually disable Origin in-game overlay in Origin Application Settings > Origin In-Game > Uncheck Enable Origin In-Game." + ])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Origin overlay disable fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + DeleteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Origin overlay marker removed."])); + } + + private bool IsOriginInstalled() + { + try + { + // Check for Origin in 64-bit and WOW64 registry views + using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(RegistryConstants.OriginKeyPath, false)) + { + if (key != null) return true; + } + + using (var wowKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(RegistryConstants.OriginKeyPathWow64, false)) + { + if (wowKey != null) return true; + } + + // Check for Origin processes + var processes = Process.GetProcessesByName("Origin"); + try + { + return processes.Length > 0; + } + finally + { + foreach (var p in processes) p.Dispose(); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking for Origin installation"); + return false; + } + } + + private bool IsOriginOverlayDisabled() + { + try + { + // Check Origin configuration file + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var originConfigPath = Path.Combine(localAppData, "Origin", "Origin.ini"); + + if (!File.Exists(originConfigPath)) + { + return false; + } + + var lines = File.ReadAllLines(originConfigPath); + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.StartsWith(';') || line.StartsWith('#') || string.IsNullOrEmpty(line)) + { + continue; + } + + var parts = line.Split('=', 2); + if (parts.Length == 2 && parts[0].Trim().Equals("OverlayEnabled", StringComparison.OrdinalIgnoreCase)) + { + var val = parts[1].Trim(); + return val.Equals("0", StringComparison.OrdinalIgnoreCase) || + val.Equals("false", StringComparison.OrdinalIgnoreCase); + } + } + + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking Origin overlay configuration"); + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EAAppRegistryFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EAAppRegistryFix.cs new file mode 100644 index 000000000..5117532dc --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EAAppRegistryFix.cs @@ -0,0 +1,258 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// Fix for EA App registry keys which are often missing or incorrect. +/// +/// The registry service. +/// The logger instance. +public class EAAppRegistryFix(IRegistryService registryService, ILogger logger) : BaseActionSet(logger) +{ + private sealed record GameRegistryConfig( + string GameName, + string? GamePath, + string AppKeyPath, + string ErgcKeyPath, + int VersionDWord, + string DefaultSerial); + + /// + public override string Id => "EAAppRegistryFix"; + + /// + public override string Title => "EA App Registry Fix"; + + /// + public override string Description => "Restores missing EA App installation paths, version DWORDs, and registry serial keys required for the game to start."; + + /// + public override string DetailedDescription => "The modern EA App client frequently fails to write standard legacy registry keys for Generals and Zero Hour, triggering misleading DirectX 8.1 or Technical Difficulties startup errors. This fix creates the official EA Games registry paths, registers accurate version DWORDs, and populates necessary serial key entries (ergc)."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Strictly only for EA App or unknown types that we want to force-fix registry for. + if (installation.InstallationType != GameInstallationType.EaApp && installation.InstallationType != GameInstallationType.Unknown) + { + return Task.FromResult(false); + } + + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + bool applied = IsGeneralsRegistryValid(installation) && IsZeroHourRegistryValid(installation); + return Task.FromResult(applied); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + // Check if running as administrator - required for HKEY_LOCAL_MACHINE writes + if (!registryService.IsRunningAsAdministrator()) + { + details.Add("✗ Administrator privileges required"); + details.Add(" Please restart GenHub as Administrator to apply registry fixes."); + return Task.FromResult(new ActionSetResult(false, "Administrator privileges required to write to HKEY_LOCAL_MACHINE.", details)); + } + + try + { + details.Add("Starting EA App registry configuration..."); + var failedOperations = new List(); + + bool generalsSucceeded = !installation.HasGenerals || ConfigureGameRegistry( + new GameRegistryConfig( + "Generals", + installation.GeneralsPath, + RegistryConstants.EAAppGeneralsKeyPath, + RegistryConstants.EAAppGeneralsErgcKeyPath, + RegistryConstants.GeneralsVersionDWord, + ActionSetConstants.Serials.DefaultEAAppGeneralsSerial), + failedOperations, + details); + + bool zeroHourSucceeded = !installation.HasZeroHour || ConfigureGameRegistry( + new GameRegistryConfig( + "Zero Hour", + installation.ZeroHourPath, + RegistryConstants.EAAppZeroHourKeyPath, + RegistryConstants.EAAppZeroHourErgcKeyPath, + RegistryConstants.ZeroHourVersionDWord, + ActionSetConstants.Serials.DefaultEAAppZeroHourSerial), + failedOperations, + details); + + if (!generalsSucceeded || !zeroHourSucceeded) + { + var errorSummary = $"Failed to write the following registry keys: {string.Join(", ", failedOperations)}. Ensure you are running as administrator."; + return Task.FromResult(new ActionSetResult(false, errorSummary, details)); + } + + details.Add("✓ EA App registry configuration completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying EA App registry fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Reverting EA App registry entries..."); + + if (installation.HasGenerals) + { + registryService.DeleteValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName); + registryService.DeleteValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName); + details.Add($"✓ Removed EA App registry entries for Generals at {RegistryConstants.EAAppGeneralsKeyPath}"); + } + + if (installation.HasZeroHour) + { + registryService.DeleteValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName); + registryService.DeleteValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName); + details.Add($"✓ Removed EA App registry entries for Zero Hour at {RegistryConstants.EAAppZeroHourKeyPath}"); + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing EA App registry fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private bool ConfigureGameRegistry( + GameRegistryConfig config, + List failedOperations, + List details) + { + if (string.IsNullOrEmpty(config.GamePath)) + { + return true; + } + + details.Add($"Configuring EA App registry for {config.GameName}: {config.GamePath}"); + bool succeeded = true; + + if (!registryService.SetStringValue(config.AppKeyPath, RegistryConstants.InstallPathValueName, config.GamePath)) + { + succeeded = false; + failedOperations.Add($"{config.AppKeyPath}\\{RegistryConstants.InstallPathValueName}"); + details.Add(" ✗ Failed to set InstallPath"); + } + else + { + details.Add($" ✓ InstallPath = {config.GamePath}"); + } + + if (!registryService.SetIntValue(config.AppKeyPath, RegistryConstants.VersionValueName, config.VersionDWord)) + { + succeeded = false; + failedOperations.Add($"{config.AppKeyPath}\\{RegistryConstants.VersionValueName}"); + details.Add(" ✗ Failed to set Version"); + } + else + { + details.Add($" ✓ Version = {config.VersionDWord}"); + } + + var existingSerial = registryService.GetStringValue(config.ErgcKeyPath, string.Empty); + if (string.IsNullOrEmpty(existingSerial)) + { + if (!registryService.SetStringValue(config.ErgcKeyPath, string.Empty, config.DefaultSerial)) + { + succeeded = false; + failedOperations.Add($"{config.ErgcKeyPath}\\(Default)"); + details.Add(" ✗ Failed to set serial key"); + } + else + { + details.Add($" ✓ Serial key created: {config.DefaultSerial}"); + } + } + else + { + details.Add(" ✓ Serial key already exists"); + } + + if (succeeded) + { + details.Add($"✓ {config.GameName} registry configuration completed"); + } + + return succeeded; + } + + private bool IsGeneralsRegistryValid(GameInstallation installation) + { + if (!installation.HasGenerals) + { + return true; + } + + return IsGameRegistryValid( + installation.GeneralsPath, + RegistryConstants.EAAppGeneralsKeyPath, + RegistryConstants.EAAppGeneralsErgcKeyPath, + RegistryConstants.GeneralsVersionDWord); + } + + private bool IsZeroHourRegistryValid(GameInstallation installation) + { + if (!installation.HasZeroHour) + { + return true; + } + + return IsGameRegistryValid( + installation.ZeroHourPath, + RegistryConstants.EAAppZeroHourKeyPath, + RegistryConstants.EAAppZeroHourErgcKeyPath, + RegistryConstants.ZeroHourVersionDWord); + } + + private bool IsGameRegistryValid(string? gamePath, string appKeyPath, string ergcKeyPath, int expectedVersion) + { + var installPath = registryService.GetStringValue(appKeyPath, RegistryConstants.InstallPathValueName); + var version = registryService.GetIntValue(appKeyPath, RegistryConstants.VersionValueName); + var serial = registryService.GetStringValue(ergcKeyPath, string.Empty); + + return string.Equals(installPath, gamePath, StringComparison.OrdinalIgnoreCase) && + version == expectedVersion && + !string.IsNullOrEmpty(serial); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EdgeScrollerFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EdgeScrollerFix.cs new file mode 100644 index 000000000..a716a47e4 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EdgeScrollerFix.cs @@ -0,0 +1,226 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.GameSettings; +using Microsoft.Extensions.Logging; + +/// +/// Fix that improves edge scrolling for modern high-resolution displays. +/// This fix adjusts edge scrolling sensitivity in Options.ini to ensure +/// smooth scrolling when mouse cursor reaches the screen edge. +/// +public class EdgeScrollerFix(ILogger logger, IGameSettingsService gameSettingsService) : BaseActionSet(logger) +{ + /// + public override string Id => "EdgeScrollerFix"; + + /// + public override string Title => "Edge Scrolling Fix"; + + /// + public override string Description => "Calibrates camera edge-scrolling zones and speed in Options.ini for responsive map scrolling on high-resolution displays."; + + /// + public override string DetailedDescription => "On modern 1080p, 1440p, and 4K displays, legacy camera edge scrolling can feel sluggish or unresponsive when the cursor reaches screen borders. This fix injects optimized edge-scrolling parameters (ScrollEdgeZone, ScrollEdgeSpeed, ScrollEdgeAcceleration) into Options.ini."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override async Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (installation.HasGenerals) + { + var result = await gameSettingsService.LoadOptionsAsync(GameType.Generals); + if (!result.Success || result.Data == null || !IsEdgeScrollingOptimal(result.Data)) + { + return false; + } + } + + if (installation.HasZeroHour) + { + var result = await gameSettingsService.LoadOptionsAsync(GameType.ZeroHour); + if (!result.Success || result.Data == null || !IsEdgeScrollingOptimal(result.Data)) + { + return false; + } + } + + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking edge scrolling status"); + return false; + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var details = new List(); + bool hasFailures = false; + + if (installation.HasGenerals) + { + var (gameDetails, success) = await ApplyEdgeScrollingFixAsync(GameType.Generals); + details.AddRange(gameDetails); + if (!success) + { + hasFailures = true; + } + } + + if (installation.HasZeroHour) + { + var (gameDetails, success) = await ApplyEdgeScrollingFixAsync(GameType.ZeroHour); + details.AddRange(gameDetails); + if (!success) + { + hasFailures = true; + } + } + + if (details.Count == 0) + { + details.Add("No games found to apply edge scrolling fix to."); + } + + if (hasFailures) + { + return new ActionSetResult(false, "Failed to apply edge scrolling fix to one or more games.", details); + } + + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying edge scrolling fix"); + return new ActionSetResult(false, ex.Message, [$"Error: {ex.Message}"]); + } + } + + /// + protected override async Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var gamesToProcess = new List(); + if (installation.HasGenerals) gamesToProcess.Add(GameType.Generals); + if (installation.HasZeroHour) gamesToProcess.Add(GameType.ZeroHour); + + foreach (var gameType in gamesToProcess) + { + var result = await gameSettingsService.LoadOptionsAsync(gameType); + if (result.Success && result.Data != null) + { + var options = result.Data; + if (options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tshSection)) + { + tshSection.Remove(ActionSetConstants.IniFiles.ScrollEdgeZoneKey); + tshSection.Remove(ActionSetConstants.IniFiles.ScrollEdgeSpeedKey); + tshSection.Remove(ActionSetConstants.IniFiles.ScrollEdgeAccelerationKey); + await gameSettingsService.SaveOptionsAsync(gameType, options); + details.Add($"✓ Removed edge scrolling settings from Options.ini for {gameType}"); + } + } + } + + return new ActionSetResult(true, null, details); + } + + private static bool IsEdgeScrollingOptimal(IniOptions options) + { + // Check if edge scrolling settings exist in TheSuperHackers section + // If the section exists with ScrollEdgeZone or ScrollEdgeSpeed, consider it applied + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tshSection)) + { + return false; + } + + // If either setting exists, consider the fix applied + return tshSection.ContainsKey(ActionSetConstants.IniFiles.ScrollEdgeZoneKey) || + tshSection.ContainsKey(ActionSetConstants.IniFiles.ScrollEdgeSpeedKey); + } + + private async Task<(List Details, bool Success)> ApplyEdgeScrollingFixAsync(GameType gameType) + { + var details = new List(); + + try + { + logger.LogInformation("Applying edge scrolling fix for {GameType}", gameType); + + var result = await gameSettingsService.LoadOptionsAsync(gameType); + if (!result.Success || result.Data == null) + { + var msg = $"⚠ Could not load Options.ini for {gameType}"; + details.Add(msg); + logger.LogWarning("Could not load settings for {GameType}", gameType); + return (details, false); + } + + var options = result.Data; + var optionsPath = gameSettingsService.GetOptionsFilePath(gameType); + + // Apply optimal edge scrolling settings + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tshSection)) + { + tshSection = []; + options.AdditionalSections[ActionSetConstants.IniFiles.TheSuperHackersSection] = tshSection; + details.Add($"✓ Created [{ActionSetConstants.IniFiles.TheSuperHackersSection}] section in Options.ini for {gameType}"); + } + + // Apply scroll settings + tshSection[ActionSetConstants.IniFiles.ScrollEdgeZoneKey] = GameSettingsConstants.OptimalSettings.ScrollEdgeZone; + tshSection[ActionSetConstants.IniFiles.ScrollEdgeSpeedKey] = GameSettingsConstants.OptimalSettings.ScrollEdgeSpeed; + tshSection[ActionSetConstants.IniFiles.ScrollEdgeAccelerationKey] = GameSettingsConstants.OptimalSettings.ScrollEdgeAcceleration; + + // Also ensure default scroll factor is good if present + if (tshSection.ContainsKey(ActionSetConstants.IniFiles.ScrollFactorKey)) + { + tshSection[ActionSetConstants.IniFiles.ScrollFactorKey] = GameSettingsConstants.OptimalSettings.ScrollFactor; + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollFactorKey}={GameSettingsConstants.OptimalSettings.ScrollFactor} for {gameType}"); + } + + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeZoneKey}={GameSettingsConstants.OptimalSettings.ScrollEdgeZone} for {gameType}"); + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeSpeedKey}={GameSettingsConstants.OptimalSettings.ScrollEdgeSpeed} for {gameType}"); + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeAccelerationKey}={GameSettingsConstants.OptimalSettings.ScrollEdgeAcceleration} for {gameType}"); + + var saveResult = await gameSettingsService.SaveOptionsAsync(gameType, options); + if (!saveResult.Success) + { + details.Add($"✗ Failed to save Options.ini for {gameType}"); + return (details, false); + } + + details.Add($"✓ Saved Options.ini: {optionsPath}"); + logger.LogInformation("Successfully applied edge scrolling fix for {GameType}", gameType); + return (details, true); + } + catch (Exception ex) + { + details.Add($"✗ Error applying edge scrolling for {gameType}: {ex.Message}"); + logger.LogError(ex, "Error applying edge scrolling fix for {GameType}", gameType); + return (details, false); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ExpandedLanLobbyMenu.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ExpandedLanLobbyMenu.cs new file mode 100644 index 000000000..83f4f1623 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ExpandedLanLobbyMenu.cs @@ -0,0 +1,143 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +/// +/// Downloads and installs custom widescreen window definitions and the expanded LAN lobby menu addon. +/// +public class ExpandedLanLobbyMenu( + IHttpClientFactory httpClientFactory, + ILogger logger, + string? markerPath = null) + : BasePackageDeploymentFix(httpClientFactory, logger, "ExpandedLANLobbyMenu.done", markerPath) +{ + private static readonly IReadOnlyList KnownMenuBigFiles = + [ + "400_ControlBarHDBaseZH.big", + "400_ControlBarHDBaseCCG.big", + "!ExpandedLANMenu.big", + "CustomWindows.big", + ]; + + /// + public override string Id => "ExpandedLANLobbyMenu"; + + /// + public override string Title => "Expanded LAN Lobby Menu (Addon)"; + + /// + public override string Description => "Downloads and installs custom widescreen UI definitions and the expanded LAN lobby menu addon."; + + /// + public override string DetailedDescription => "Replaces the legacy 4-row LAN lobby interface and cramped window definitions with a widescreen-adapted layout. This addon downloads the official widescreen window assets and installs them into your game folder. You can also download and manage this addon from the Downloads section."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + protected override IReadOnlyList DownloadUrls => + [ + ExternalUrls.ExpandedLANLobbyDownloadUrlPrimary, + ExternalUrls.ExpandedLANLobbyDownloadUrlMirror1, + ]; + + /// + protected override string ExpectedSha256 => ActionSetConstants.Security.ExpandedLANLobbySha256; + + /// + protected override string PackageDisplayName => "Expanded LAN Lobby & Custom Windows"; + + /// + protected override string TempFilePrefix => "cbbs"; + + /// + protected override async Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync( + string archivePath, + DeploymentContext context, + GameInstallation installation, + CancellationToken ct) + { + using var archive = ArchiveFactory.OpenArchive(new FileInfo(archivePath)); + var extractedFiles = await ExtractArchiveEntriesAsync(archive, context.TempExtractDir, ct); + + foreach (var (fileName, extractedFilePath) in extractedFiles) + { + DeployEntryToInstallations(installation, fileName, extractedFilePath, context); + } + + return (extractedFiles.Count, context.DeployedFiles); + } + + /// + protected override bool AreAssetsPresent(GameInstallation installation) + { + try + { + if (installation.HasZeroHour && + !string.IsNullOrEmpty(installation.ZeroHourPath) && + KnownMenuBigFiles.Any(f => File.Exists(Path.Combine(installation.ZeroHourPath, f)))) + { + return true; + } + + return installation.HasGenerals && + !string.IsNullOrEmpty(installation.GeneralsPath) && + KnownMenuBigFiles.Any(f => File.Exists(Path.Combine(installation.GeneralsPath, f))); + } + catch (IOException ex) + { + logger.LogWarning(ex, "I/O error checking LAN lobby menu status"); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Permission denied checking LAN lobby menu status"); + return false; + } + } + + /// + protected override List GetLegacyFilePaths(GameInstallation installation) + { + var legacyFiles = new List(); + CollectExistingFiles(installation.ZeroHourPath, KnownMenuBigFiles, legacyFiles); + CollectExistingFiles(installation.GeneralsPath, KnownMenuBigFiles, legacyFiles); + return legacyFiles; + } + + private static void DeployEntryToInstallations( + GameInstallation installation, + string fileName, + string sourceFilePath, + DeploymentContext context) + { + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var zhDest = Path.Combine(installation.ZeroHourPath, fileName); + DeployFileWithBackup(sourceFilePath, zhDest, context); + } + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && + !string.Equals(installation.GeneralsPath, installation.ZeroHourPath, StringComparison.OrdinalIgnoreCase)) + { + var generalsDest = Path.Combine(installation.GeneralsPath, fileName); + DeployFileWithBackup(sourceFilePath, generalsDest, context); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/FirewallExceptionFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/FirewallExceptionFix.cs new file mode 100644 index 000000000..88433ba24 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/FirewallExceptionFix.cs @@ -0,0 +1,331 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that adds Windows Firewall exceptions for game executables to allow multiplayer. +/// Uses the same rule names as GenPatcher for compatibility. +/// +public class FirewallExceptionFix(ILogger logger) : BaseActionSet(logger) +{ + // GenPatcher-compatible rule names + private const string PortRuleUdp16000 = ActionSetConstants.FirewallRules.PortRuleUdp16000; + private const string PortRuleUdp16001 = ActionSetConstants.FirewallRules.PortRuleUdp16001; + private const string PortRuleTcp16001 = ActionSetConstants.FirewallRules.PortRuleTcp16001; + + private const string GeneralsRule = ActionSetConstants.FirewallRules.GeneralsRule; + private const string GeneralsGameDatRule = ActionSetConstants.FirewallRules.GeneralsGameDatRule; + private const string ZeroHourRule = ActionSetConstants.FirewallRules.ZeroHourRule; + private const string ZeroHourGameDatRule = ActionSetConstants.FirewallRules.ZeroHourGameDatRule; + + private static readonly string NetshPath = Path.Combine(Environment.SystemDirectory, "netsh.exe"); + + /// + public override string Id => "FirewallExceptionFix"; + + /// + public override string Title => "Windows Firewall Exceptions"; + + /// + public override string Description => "Adds Windows Defender Firewall inbound exception rules for game executables and multiplayer ports (UDP/TCP 16000-16001)."; + + /// + public override string DetailedDescription => "Windows Firewall frequently blocks the peer-to-peer UDP and TCP packets used by Generals and Zero Hour for multiplayer networking, leading to connection timeouts. This fix creates dedicated inbound firewall rules for game executables and open multiplayer ports (UDP 16000, UDP 16001, TCP 16001)."; + + /// + public override string Category => ActionSetConstants.Categories.Multiplayer; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var hasPortRule = IsFirewallRuleExists(PortRuleUdp16000); + logger.LogInformation("Firewall rule '{RuleName}' exists: {Exists}", PortRuleUdp16000, hasPortRule); + return Task.FromResult(hasPortRule); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking firewall rules status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + if (IsFirewallRuleExists(PortRuleUdp16000)) + { + details.Add("✓ Firewall rules already applied (found GP Open UDP Port 16000)"); + logger.LogInformation("Firewall rules already applied"); + return new ActionSetResult(true, null, details); + } + + var (rulesAdded, rulesFailed) = await Task.Run( + () => ApplyAllRules(installation, details), + ct); + + if (rulesAdded == 0 && rulesFailed > 0) + { + logger.LogWarning("Firewall rule configuration failed completely. Administrative privileges may be required."); + return new ActionSetResult(false, "Failed to configure any firewall rules. Administrative privileges may be required.", details); + } + + if (rulesFailed > 0) + { + logger.LogWarning("Firewall exceptions applied with {FailedCount} failures out of {TotalCount}", rulesFailed, rulesAdded + rulesFailed); + return new ActionSetResult(false, $"Failed to add {rulesFailed} firewall rule(s).", details); + } + + logger.LogInformation("All {Count} firewall rules added successfully", rulesAdded); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying firewall exception fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override async Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Removing firewall rules..."); + + var (rulesRemoved, rulesFailed) = await Task.Run( + () => RemoveAllRules(details), + ct); + + logger.LogInformation("Firewall rules removal finished: {RemovedCount} removed, {FailedCount} failed", rulesRemoved, rulesFailed); + if (rulesFailed > 0) + { + return new ActionSetResult(false, $"Failed to remove {rulesFailed} firewall rule(s).", details); + } + + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing firewall exception fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + private (int Added, int Failed) ApplyAllRules(GameInstallation installation, List details) + { + int added = 0; + int failed = 0; + + TryAddPortRule(PortRuleUdp16000, ActionSetConstants.FirewallRules.ProtocolUdp, 16000, details, ref added, ref failed); + TryAddPortRule(PortRuleUdp16001, ActionSetConstants.FirewallRules.ProtocolUdp, 16001, details, ref added, ref failed); + TryAddPortRule(PortRuleTcp16001, ActionSetConstants.FirewallRules.ProtocolTcp, 16001, details, ref added, ref failed); + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var generalsExe = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GeneralsExe); + var generalsGameDat = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GameDat); + TryAddProgramRule(GeneralsRule, generalsExe, details, ref added, ref failed); + TryAddProgramRule(GeneralsGameDatRule, generalsGameDat, details, ref added, ref failed); + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var zeroHourExe = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GeneralsExe); + var zeroHourGameDat = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameDat); + TryAddProgramRule(ZeroHourRule, zeroHourExe, details, ref added, ref failed); + TryAddProgramRule(ZeroHourGameDatRule, zeroHourGameDat, details, ref added, ref failed); + } + + return (added, failed); + } + + private void TryAddPortRule(string ruleName, string protocol, int port, List details, ref int rulesAdded, ref int rulesFailed) + { + if (AddPortRule(ruleName, protocol, port)) + { + rulesAdded++; + details.Add($"✓ Added port rule: {ruleName} ({protocol.ToUpperInvariant()} {port})"); + } + else + { + rulesFailed++; + details.Add($"⚠ Failed: {ruleName}"); + } + } + + private void TryAddProgramRule(string ruleName, string path, List details, ref int rulesAdded, ref int rulesFailed) + { + if (!File.Exists(path)) + { + return; + } + + if (AddProgramRule(ruleName, path)) + { + rulesAdded++; + details.Add($"✓ Added rule: {ruleName}"); + } + else + { + rulesFailed++; + details.Add($"⚠ Failed: {ruleName}"); + } + } + + private (int Removed, int Failed) RemoveAllRules(List details) + { + int removed = 0; + int failed = 0; + + string[] rules = + [ + PortRuleUdp16000, + PortRuleUdp16001, + PortRuleTcp16001, + GeneralsRule, + GeneralsGameDatRule, + ZeroHourRule, + ZeroHourGameDatRule, + ]; + + foreach (var rule in rules) + { + if (RemoveFirewallRule(rule)) + { + removed++; + details.Add($"✓ Removed rule: {rule}"); + } + else + { + failed++; + details.Add($"⚠ Failed to remove rule: {rule}"); + } + } + + return (removed, failed); + } + + private bool IsFirewallRuleExists(string ruleName) + { + try + { + var psi = new ProcessStartInfo + { + FileName = NetshPath, + Arguments = $"advfirewall firewall show rule name=\"{ruleName}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + var output = process.StandardOutput.ReadToEnd(); + _ = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + return process.ExitCode == ProcessConstants.ExitCodeSuccess && + !string.IsNullOrWhiteSpace(output) && + !output.Contains("No rules", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking if firewall rule exists: {RuleName}", ruleName); + return false; + } + } + + private bool AddPortRule(string ruleName, string protocol, int port) => + RunNetshCommand($"advfirewall firewall add rule name=\"{ruleName}\" dir=in action=allow edge=yes protocol={protocol} localport={port}", ruleName, isAdd: true); + + private bool AddProgramRule(string ruleName, string programPath) => + RunNetshCommand($"advfirewall firewall add rule name=\"{ruleName}\" dir=in action=allow edge=yes program=\"{programPath}\" enable=yes", ruleName, isAdd: true); + + private bool RemoveFirewallRule(string ruleName) => + RunNetshCommand($"advfirewall firewall delete rule name=\"{ruleName}\"", ruleName, isAdd: false); + + private bool RunNetshCommand(string arguments, string ruleName, bool isAdd = false) + { + try + { + logger.LogInformation("Running: netsh {Args}", arguments); + var psi = new ProcessStartInfo + { + FileName = NetshPath, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + _ = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != ProcessConstants.ExitCodeSuccess) + { + if (isAdd) + { + logger.LogError("netsh failed with exit code {ExitCode} for rule {RuleName}: {Error}", process.ExitCode, ruleName, stderr); + } + else + { + logger.LogDebug("netsh returned exit code {ExitCode} for rule {RuleName}", process.ExitCode, ruleName); + } + + return false; + } + + return true; + } + + return false; + } + catch (Exception ex) + { + if (isAdd) + { + logger.LogError(ex, "Error running netsh command '{Args}' for rule {RuleName}", arguments, ruleName); + } + else + { + logger.LogWarning(ex, "Error running netsh command '{Args}' for rule {RuleName}", arguments, ruleName); + } + + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GameRangerRunAsAdmin.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GameRangerRunAsAdmin.cs new file mode 100644 index 000000000..692f266a0 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GameRangerRunAsAdmin.cs @@ -0,0 +1,219 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides GameRanger compatibility guidance. +/// GameRanger requires games to run as administrator for proper functionality. +/// +public class GameRangerRunAsAdmin(ILogger logger) : BaseActionSet(logger) +{ + private static readonly IReadOnlyList GeneralsExecutables = ["Generals.exe", "generals.exe"]; + private static readonly IReadOnlyList ZeroHourExecutables = ["generals.exe", "game.dat", "game.exe", "generalszh.exe"]; + + /// + public override string Id => "GameRangerRunAsAdmin"; + + /// + public override string Title => "GameRanger Run as Administrator"; + + /// + public override string Description => "Verifies GameRanger integration and guides configuring administrator privileges to allow GameRanger to launch multiplayer lobbies."; + + /// + public override string DetailedDescription => "When playing via the GameRanger client, the game executable must run with administrator privileges so GameRanger can inject its room and network parameters. This fix detects GameRanger installations and verifies compatibility flags to prevent launch freezes."; + + /// + public override string Category => ActionSetConstants.Categories.Multiplayer; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if GameRanger IS installed + var gameRangerInstalled = IsGameRangerInstalled(); + return Task.FromResult(gameRangerInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + // Check if GameRanger is installed + var gameRangerInstalled = IsGameRangerInstalled(); + + if (!gameRangerInstalled) + { + // If GameRanger is not installed, it's not applied (it's N/A) + return Task.FromResult(false); + } + + // Check if game executables have run as admin compatibility + var hasAdminCompat = HasAdminCompatibility(installation); + + return Task.FromResult(hasAdminCompat); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking GameRanger compatibility status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var gameRangerInstalled = IsGameRangerInstalled(); + + if (!gameRangerInstalled) + { + logger.LogInformation("GameRanger is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + if (HasAdminCompatibility(installation)) + { + logger.LogInformation("Game executables already have run as administrator compatibility."); + return Task.FromResult(new ActionSetResult(true)); + } + + logger.LogWarning("GameRanger is installed. Games should run as administrator for GameRanger compatibility. Please configure GameRanger or game shortcut compatibility."); + + return Task.FromResult(new ActionSetResult(true, null, ["Please configure GameRanger to run games as administrator. See logs for details."])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying GameRanger compatibility fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogWarning("GameRanger Run as Administrator Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static bool CheckUninstallKey(Microsoft.Win32.RegistryKey baseKey, string subPath) + { + using var key = baseKey.OpenSubKey(subPath, false); + if (key != null) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey?.GetValue(RegistryConstants.DisplayNameValueName) is string displayName && displayName.Contains("GameRanger", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + private static List GetExistingGameExecutables(GameInstallation installation) + { + var executables = new List(); + + if (installation.HasGenerals) + { + foreach (var exe in GeneralsExecutables) + { + var full = Path.Combine(installation.GeneralsPath, exe); + if (File.Exists(full)) executables.Add(full); + } + } + + if (installation.HasZeroHour) + { + foreach (var exe in ZeroHourExecutables) + { + var full = Path.Combine(installation.ZeroHourPath, exe); + if (File.Exists(full)) executables.Add(full); + } + } + + return executables; + } + + private static bool IsAnyExeConfiguredWithRunAsAdmin(IEnumerable executables) + { + using var hklmKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(RegistryConstants.AppCompatLayersKeyPath, false); + using var hkcuKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(RegistryConstants.AppCompatLayersKeyPath, false); + + foreach (var exePath in executables) + { + if (hklmKey?.GetValue(exePath) is string hklmFlags && hklmFlags.Contains("RUNASADMIN", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (hkcuKey?.GetValue(exePath) is string hkcuFlags && hkcuFlags.Contains("RUNASADMIN", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private bool IsGameRangerInstalled() + { + try + { + // Check for GameRanger in registry (HKLM, WOW6432Node, HKCU) + if (CheckUninstallKey(Microsoft.Win32.Registry.LocalMachine, RegistryConstants.UninstallKeyPath)) return true; + if (CheckUninstallKey(Microsoft.Win32.Registry.LocalMachine, RegistryConstants.UninstallKeyPathWow64)) return true; + if (CheckUninstallKey(Microsoft.Win32.Registry.CurrentUser, RegistryConstants.UninstallKeyPath)) return true; + + // Check for GameRanger processes + var processes = Process.GetProcessesByName("GameRanger"); + try + { + return processes.Length > 0; + } + finally + { + foreach (var p in processes) p.Dispose(); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking for GameRanger installation"); + return false; + } + } + + private bool HasAdminCompatibility(GameInstallation installation) + { + try + { + var executables = GetExistingGameExecutables(installation); + return IsAnyExeConfiguredWithRunAsAdmin(executables); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking admin compatibility"); + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenArial.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenArial.cs new file mode 100644 index 000000000..54f73829a --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenArial.cs @@ -0,0 +1,147 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that ensures Arial font is available for the game. +/// Generals and Zero Hour require Arial font for proper text rendering. +/// +public class GenArial(ILogger logger) : BaseActionSet(logger) +{ + private static readonly IReadOnlyList ArialFiles = + [ + "arial.ttf", + "arialbd.ttf", + "ariali.ttf", + "arialbi.ttf", + "ARIAL.TTF", + ]; + + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "GenArial.done"); + + /// + public override string Id => "GenArial"; + + /// + public override string Title => "Arial Font"; + + /// + public override string Description => "Verifies standard TrueType Arial fonts are installed so all in-game menus, HUD, and chat text render properly."; + + /// + public override string DetailedDescription => "Generals and Zero Hour depend on standard TrueType Arial fonts to render in-game menus, UI buttons, and chat overlays. On streamlined or modified Windows editions lacking standard fonts, in-game text can render as invisible or corrupted boxes. This fix checks font availability and guides installation if needed."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Arial is NOT installed (needs to be fixed) + var arialInstalled = IsArialFontInstalled(); + return Task.FromResult(!arialInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + if (MarkerExists(_markerPath)) return Task.FromResult(true); + return Task.FromResult(IsArialFontInstalled()); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var arialInstalled = IsArialFontInstalled(); + + if (arialInstalled) + { + logger.LogInformation("Arial font is already installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Provide guidance for installing Arial font + logger.LogWarning("Arial font is not installed. This may cause text rendering issues. Please install Arial from Windows Settings > Optional features > Add a font."); + + WriteMarkerFile(_markerPath); + + return Task.FromResult(new ActionSetResult(true, null, ["Please manually install Arial font. See logs for details."])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Arial font fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + DeleteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Arial font marker removed."])); + } + + private bool IsArialFontInstalled() + { + try + { + // Check for Arial font in Windows fonts directory + var fontsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "Fonts"); + + var existingFont = ArialFiles.FirstOrDefault(fontFile => File.Exists(Path.Combine(fontsPath, fontFile))); + if (existingFont != null) + { + logger.LogInformation("Found Arial font: {Font}", existingFont); + return true; + } + + // Check for Arial in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + RegistryConstants.FontsKeyPath, + false); + + if (key != null) + { + if (key.GetValue(RegistryConstants.ArialFontValueName) != null) + { + logger.LogInformation("Found Arial font in registry: {Font}", RegistryConstants.ArialFontValueName); + return true; + } + + var fontValueName = key.GetValueNames().FirstOrDefault(v => v.Contains("Arial", StringComparison.OrdinalIgnoreCase)); + if (fontValueName != null) + { + logger.LogInformation("Found Arial font in registry: {Font}", fontValueName); + return true; + } + } + + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking for Arial font"); + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenToolFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenToolFix.cs new file mode 100644 index 000000000..8fd1f0bad --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenToolFix.cs @@ -0,0 +1,246 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +/// +/// Installs GenTool (d3d8.dll), which provides essential fixes, anti-cheat, and widescreen support. +/// This matches GenPatcher's 'GenTool' action set. +/// +public class GenToolFix(ILogger logger, IHttpClientFactory httpClientFactory) : BaseActionSet(logger) +{ + private const string D3D8Dll = "d3d8.dll"; + + /// + public override string Id => "GenToolFix"; + + /// + public override string Title => "GenTool (Addon)"; + + /// + public override string Description => "Installs the community GenTool engine wrapper for widescreen resolutions and anti-cheat (also managed in Downloads)."; + + /// + public override string DetailedDescription => "GenTool is the standard community add-on for Generals and Zero Hour operating via Direct3D hook (d3d8.dll). It enables true widescreen display rendering without vertical image cropping, uncap/smooth camera controls, enhanced match recording, and anti-cheat validation. You can also download and manage GenTool from the Downloads section."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + bool appliedGenerals = !installation.HasGenerals || File.Exists(Path.Combine(installation.GeneralsPath, D3D8Dll)); + bool appliedZeroHour = !installation.HasZeroHour || File.Exists(Path.Combine(installation.ZeroHourPath, D3D8Dll)); + return Task.FromResult(appliedGenerals && appliedZeroHour); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var tempFile = Path.Combine(Path.GetTempPath(), $"gentool_setup_{Guid.NewGuid():N}.dat"); + var tempExtractDir = Path.Combine(Path.GetTempPath(), $"gentool_extract_{Guid.NewGuid():N}"); + var details = new List(); + + try + { + details.Add("Downloading GenTool..."); + var downloadSuccess = await TryDownloadFromMirrorsAsync(tempFile, details, ct); + if (!downloadSuccess) + { + return new ActionSetResult(false, "Failed to download and authenticate GenTool from all mirrors.", details); + } + + details.Add($"Extracting and verifying GenTool ({D3D8Dll})..."); + var (extractSuccess, extractedDllPath, extractError) = await ExtractAndVerifyDllAsync(tempFile, tempExtractDir, ct); + if (!extractSuccess || string.IsNullOrEmpty(extractedDllPath)) + { + return new ActionSetResult(false, extractError ?? $"Failed to extract {D3D8Dll}.", details); + } + + var deployResult = await DeployDllAsync(extractedDllPath, installation, details, ct); + if (!deployResult.Success) + { + return deployResult; + } + + details.Add($"ℹ Note: You may need to add '{D3D8Dll}' to Windows Defender exclusions manually."); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to apply GenTool fix"); + return new ActionSetResult(false, $"Error: {ex.Message}", details); + } + finally + { + DeleteFileSafely(tempFile); + DeleteDirectorySafely(tempExtractDir); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var p = Path.Combine(installation.GeneralsPath, D3D8Dll); + if (File.Exists(p)) + { + File.Delete(p); + } + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var p = Path.Combine(installation.ZeroHourPath, D3D8Dll); + if (File.Exists(p)) + { + File.Delete(p); + } + } + + return Task.FromResult(new ActionSetResult(true, null, ["GenTool (d3d8.dll) removed from installation."])); + } + + private static Task DeployDllAsync(string extractedDllPath, GameInstallation installation, List details, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + int deployedCount = 0; + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var dest = Path.Combine(installation.GeneralsPath, D3D8Dll); + File.Copy(extractedDllPath, dest, overwrite: true); + details.Add($"✓ Installed GenTool to Generals: {dest}"); + deployedCount++; + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var dest = Path.Combine(installation.ZeroHourPath, D3D8Dll); + File.Copy(extractedDllPath, dest, overwrite: true); + details.Add($"✓ Installed GenTool to Zero Hour: {dest}"); + deployedCount++; + } + + if (deployedCount == 0) + { + return Task.FromResult(new ActionSetResult(false, "No valid game installation directory found to install GenTool.", details)); + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + private async Task TryDownloadFromMirrorsAsync(string tempFile, List details, CancellationToken ct) + { + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + var urls = new[] + { + ExternalUrls.GenToolDownloadUrlPrimary, + ExternalUrls.GenToolDownloadUrlMirror1, + }; + + foreach (var url in urls) + { + try + { + logger.LogInformation("Attempting GenTool download from {Url}", url); + using var response = await client.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + await using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true)) + { + await response.Content.CopyToAsync(fs, ct); + } + + var fileInfo = new FileInfo(tempFile); + if (fileInfo.Length < ActionSetConstants.Validation.MinimumAddonPackageSizeBytes) + { + logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Skipping mirror.", url, fileInfo.Length); + DeleteFileSafely(tempFile); + continue; + } + + var validation = await DownloadSecurityValidator.ValidateFileAsync( + tempFile, + allowedSha256Hashes: [ActionSetConstants.Security.GenToolArchiveSha256], + ct: ct); + + if (!validation.Success) + { + logger.LogWarning("Validation failed for {Url}: {Error}", url, string.Join("; ", validation.Errors)); + DeleteFileSafely(tempFile); + continue; + } + + details.Add($"✓ Downloaded and authenticated from {new Uri(url).Host}"); + return true; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Download failed from {Url}", url); + DeleteFileSafely(tempFile); + } + } + + return false; + } + + private async Task<(bool Success, string? ExtractedDllPath, string? ErrorMessage)> ExtractAndVerifyDllAsync(string zipPath, string tempExtractDir, CancellationToken ct) + { + Directory.CreateDirectory(tempExtractDir); + using var archive = ArchiveFactory.OpenArchive(new FileInfo(zipPath)); + + var d3d8Entry = archive.Entries.FirstOrDefault(e => + !e.IsDirectory && + string.Equals(Path.GetFileName(e.Key), D3D8Dll, StringComparison.OrdinalIgnoreCase)); + + if (d3d8Entry == null) + { + return (false, null, $"Archive does not contain required '{D3D8Dll}'."); + } + + var extractedDllPath = Path.Combine(tempExtractDir, D3D8Dll); + await using var entryStream = await d3d8Entry.OpenEntryStreamAsync(ct); + await using var fs = new FileStream(extractedDllPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + await entryStream.CopyToAsync(fs, ct); + + var dllValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync( + extractedDllPath, + allowedSha256Hashes: [ActionSetConstants.Security.GenToolD3D8DllSha256], + ct: ct); + + if (!dllValidation.Success || dllValidation.Data == null) + { + var errorSummary = string.Join("; ", dllValidation.Errors); + logger.LogWarning("Security validation failed for extracted GenTool {Dll}: {Error}", D3D8Dll, errorSummary); + return (false, null, $"Security validation failed for GenTool {D3D8Dll}: {errorSummary}"); + } + + await dllValidation.Data.DisposeAsync(); + return (true, extractedDllPath, null); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/HDIconsFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/HDIconsFix.cs new file mode 100644 index 000000000..ae54a2f46 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/HDIconsFix.cs @@ -0,0 +1,198 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Validation; +using Microsoft.Extensions.Logging; +using SharpCompress.Archives; + +/// +/// Fix that downloads and installs high-definition icons for Generals and Zero Hour. +/// Replaces legacy 32x32 Windows XP icons with 256x256 HD icon assets. +/// +public class HDIconsFix( + IHttpClientFactory httpClientFactory, + ILogger logger, + string? markerPath = null) + : BasePackageDeploymentFix(httpClientFactory, logger, "HDIconsFix.done", markerPath) +{ + private static readonly IReadOnlyList RecognizedGeneralsIconFiles = + [ + "GeneralsHD.ico", + "generals_hd.ico", + "game_hd.ico", + ]; + + private static readonly IReadOnlyList RecognizedZeroHourIconFiles = + [ + "GeneralsZHHD.ico", + "zh_hd.ico", + ]; + + /// + public override string Id => "HDIconsFix"; + + /// + public override string Title => "HD Icons (Addon)"; + + /// + public override string Description => "Installs high-definition 256x256 icon assets for game shortcuts (also managed in Downloads)."; + + /// + public override string DetailedDescription => "Replaces low-resolution 32x32 icons with 256x256 icon files for desktop shortcuts and taskbar windows. This addon downloads icon.dat from Community Outpost and extracts HD icons directly into your game directories. You can also download and manage this addon from the Downloads section."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + protected override IReadOnlyList DownloadUrls => [ExternalUrls.HDIconsDownloadUrlPrimary]; + + /// + protected override string ExpectedSha256 => ActionSetConstants.Security.HDIconsSha256; + + /// + protected override string PackageDisplayName => "High-Definition Icons"; + + /// + protected override string TempFilePrefix => "hd_icons"; + + /// + /// Validates that the downloaded HD icons archive contains the expected icon assets for targeted installations. + /// + /// The set of file names in the archive. + /// The targeted game installation. + /// A validation result indicating validity and any issues found. + internal static ValidationResult ValidateArchiveContents( + IReadOnlySet archiveFileNames, + GameInstallation installation) + { + var issues = new List(); + + if (archiveFileNames.Count == 0) + { + issues.Add(new ValidationIssue { Message = "HD icons archive contains no valid files.", Severity = ValidationSeverity.Error }); + return new ValidationResult("HDIconsPackage", issues); + } + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && RecognizedGeneralsIconFiles.All(f => !archiveFileNames.Contains(f))) + { + issues.Add(new ValidationIssue { Message = "HD icons package does not contain a recognized icon for Generals.", Severity = ValidationSeverity.Error }); + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath) && RecognizedZeroHourIconFiles.All(f => !archiveFileNames.Contains(f))) + { + issues.Add(new ValidationIssue { Message = "HD icons package does not contain a recognized icon for Zero Hour.", Severity = ValidationSeverity.Error }); + } + + return new ValidationResult("HDIconsPackage", issues); + } + + /// + protected override async Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync( + string archivePath, + DeploymentContext context, + GameInstallation installation, + CancellationToken ct) + { + using var archive = ArchiveFactory.OpenArchive(new FileInfo(archivePath)); + var archiveFileNames = archive.Entries + .Where(e => !e.IsDirectory && !string.IsNullOrEmpty(e.Key)) + .Select(e => Path.GetFileName(e.Key)) + .Where(n => !string.IsNullOrEmpty(n)) + .OfType() + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var archiveValidation = ValidateArchiveContents(archiveFileNames, installation); + if (!archiveValidation.IsValid) + { + var errorMessage = archiveValidation.FirstError ?? "HD icons package validation failed."; + logger.LogWarning("{Error}", errorMessage); + return (0, null); + } + + var extractedFiles = await ExtractArchiveEntriesAsync(archive, context.TempExtractDir, ct); + + foreach (var (fileName, extractedFilePath) in extractedFiles) + { + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && + RecognizedGeneralsIconFiles.Contains(fileName, StringComparer.OrdinalIgnoreCase)) + { + var generalsDest = Path.Combine(installation.GeneralsPath, fileName); + DeployFileWithBackup(extractedFilePath, generalsDest, context); + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath) && + RecognizedZeroHourIconFiles.Contains(fileName, StringComparer.OrdinalIgnoreCase) && + (!string.Equals(installation.GeneralsPath, installation.ZeroHourPath, StringComparison.OrdinalIgnoreCase) || + !RecognizedGeneralsIconFiles.Contains(fileName, StringComparer.OrdinalIgnoreCase))) + { + var zhDest = Path.Combine(installation.ZeroHourPath, fileName); + DeployFileWithBackup(extractedFilePath, zhDest, context); + } + } + + return (extractedFiles.Count, context.DeployedFiles); + } + + /// + protected override bool AreAssetsPresent(GameInstallation installation) + { + try + { + var hasAnyTarget = false; + + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + hasAnyTarget = true; + if (RecognizedGeneralsIconFiles.All(iconFile => !File.Exists(Path.Combine(installation.GeneralsPath, iconFile)))) + { + return false; + } + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + hasAnyTarget = true; + if (RecognizedZeroHourIconFiles.All(iconFile => !File.Exists(Path.Combine(installation.ZeroHourPath, iconFile)))) + { + return false; + } + } + + return hasAnyTarget; + } + catch (IOException ex) + { + logger.LogWarning(ex, "I/O error checking for HD icons"); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Permission denied checking for HD icons"); + return false; + } + } + + /// + protected override List GetLegacyFilePaths(GameInstallation installation) + { + var legacyFiles = new List(); + CollectExistingFiles(installation.GeneralsPath, RecognizedGeneralsIconFiles, legacyFiles); + CollectExistingFiles(installation.ZeroHourPath, RecognizedZeroHourIconFiles, legacyFiles); + return legacyFiles; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/IntelGfxDriverCompatibility.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/IntelGfxDriverCompatibility.cs new file mode 100644 index 000000000..e3840dac7 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/IntelGfxDriverCompatibility.cs @@ -0,0 +1,185 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Diagnostics; +using System.IO; +using System.Management; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides Intel graphics driver compatibility guidance. +/// Intel graphics drivers may have compatibility issues with older DirectX games. +/// +public class IntelGfxDriverCompatibility(ILogger logger) : BaseActionSet(logger) +{ + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "IntelGfxDriverCompatibility.done"); + + /// + public override string Id => "IntelGfxDriverCompatibility"; + + /// + public override string Title => "Intel Graphics Driver Compatibility"; + + /// + public override string Description => "Detects Intel integrated/discrete GPUs and guides updating drivers to prevent black screens and texture corruption."; + + /// + public override string DetailedDescription => "Older DirectX 8 titles frequently encounter rendering anomalies, flashing water shaders, or black-screen crashes on Intel integrated and Arc graphics. This fix identifies Intel display adapters and guides installing the latest driver revisions to maintain rendering stability."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Intel graphics are present + var hasIntelGfx = HasIntelGraphics(); + return Task.FromResult(hasIntelGfx && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + // Check if Intel graphics is present + var hasIntelGfx = HasIntelGraphics(); + + if (!hasIntelGfx) + { + // If Intel graphics is not present, it's not applicable + return Task.FromResult(false); + } + + if (MarkerExists(_markerPath)) return Task.FromResult(true); + + // Check if Intel graphics driver is up to date + var driverUpToDate = IsIntelDriverUpToDate(); + + return Task.FromResult(driverUpToDate); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking Intel graphics driver status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var hasIntelGfx = HasIntelGraphics(); + + if (!hasIntelGfx) + { + logger.LogInformation("Intel graphics not detected. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + if (IsIntelDriverUpToDate()) + { + logger.LogInformation("Intel graphics driver is up to date. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + logger.LogWarning("Intel graphics driver detected. May need update from Intel website: {Url}", ExternalUrls.IntelDriverDownloadUrl); + + WriteMarkerFile(_markerPath); + + return Task.FromResult(new ActionSetResult(true, null, ["Please update Intel graphics driver. See logs for details."])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Intel graphics driver compatibility fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + DeleteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Intel graphics marker removed."])); + } + + private bool HasIntelGraphics() + { + try + { + // Check for Intel graphics in system + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + $@"{RegistryConstants.IntelGraphicsClassKeyPath}\0000", + false); + + if (key?.GetValue("DriverDesc") is string driverDesc && driverDesc.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Found Intel graphics: {Driver}", driverDesc); + return true; + } + + // Check for Intel graphics via WMI + using var searcher = new ManagementObjectSearcher(RegistryConstants.WmiScopeCimV2, RegistryConstants.WmiQueryVideoController); + using var results = searcher.Get(); + + foreach (ManagementBaseObject result in results) + { + using (result) + { + if (result["Name"] is string name && name.Contains("Intel", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation("Found Intel graphics via WMI: {Name}", name); + return true; + } + } + } + + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking for Intel graphics"); + return false; + } + } + + private bool IsIntelDriverUpToDate() + { + try + { + // This is a simplified check - actual driver version checking is complex + // We'll check if Intel Driver & Support Assistant is installed + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + RegistryConstants.IntelMEWizKeyPath, + false); + + if (key?.GetValue("Version") is string version) + { + logger.LogInformation("Intel Driver & Support Assistant version: {Version}", version); + + // Assume recent version means driver is reasonably up to date + return true; + } + + // If we can't determine, assume it needs checking + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking Intel driver version"); + return false; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MalwarebytesFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MalwarebytesFix.cs new file mode 100644 index 000000000..2d38fd0c8 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MalwarebytesFix.cs @@ -0,0 +1,140 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides Malwarebytes compatibility guidance for game executables. +/// This fix checks for Malwarebytes installation and provides instructions +/// to add game folders to Malwarebytes exclusions to prevent interference. +/// +public class MalwarebytesFix(ILogger logger) : BaseActionSet(logger) +{ + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "MalwarebytesFix.done"); + + /// + public override string Id => "MalwarebytesFix"; + + /// + public override string Title => "Malwarebytes Compatibility"; + + /// + public override string Description => "Detects Malwarebytes and provides exclusion instructions to prevent false-positive blocking of game binaries."; + + /// + public override string DetailedDescription => "Malwarebytes real-time heuristic scanning can falsely flag legacy game binaries, community patches, and GenTool DLL hooks, leading to silent launch failures. This fix detects installed Malwarebytes software and provides exact paths to add your game folders to antivirus exclusions."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Malwarebytes is actually installed (something to check/warn about) + var mbamInstalled = IsMalwarebytesInstalled(); + return Task.FromResult(mbamInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(MarkerExists(_markerPath)); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Malwarebytes Compatibility - Informational"); + details.Add(string.Empty); + + var mbamInstalled = IsMalwarebytesInstalled(); + + if (!mbamInstalled) + { + details.Add("✓ Malwarebytes is not installed"); + details.Add(" No action needed"); + logger.LogInformation("Malwarebytes is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + var paths = new List(); + + if (installation.HasGenerals) + { + paths.Add(installation.GeneralsPath); + } + + if (installation.HasZeroHour) + { + paths.Add(installation.ZeroHourPath); + } + + details.Add("⚠ Malwarebytes detected"); + details.Add(" Please add the following folders to exclusions:"); + details.Add(string.Empty); + foreach (var path in paths) + { + details.Add($" • {path}"); + } + + details.Add(string.Empty); + details.Add("To add exclusions in Malwarebytes:"); + details.Add(" 1. Open Malwarebytes"); + details.Add(" 2. Go to Settings > Exclusions"); + details.Add(" 3. Click 'Add Folder' and select the game folders listed above"); + details.Add(" 4. Click 'Done' to save changes"); + + logger.LogWarning("Malwarebytes is installed. Please manually add game folders to Malwarebytes exclusions: {Paths}", string.Join(", ", paths)); + + WriteMarkerFile(_markerPath); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Malwarebytes compatibility fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + DeleteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Malwarebytes marker removed."])); + } + + private static bool IsMalwarebytesInstalled() + { + // Fallback: Check common installation paths + foreach (var path in ActionSetConstants.Malwarebytes.ExecutablePaths) + { + var fullPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), path); + if (File.Exists(fullPath)) return true; + + var fullPath86 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), path); + if (File.Exists(fullPath86)) return true; + } + + return false; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MyDocumentsPathCompatibility.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MyDocumentsPathCompatibility.cs new file mode 100644 index 000000000..1aed48de1 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MyDocumentsPathCompatibility.cs @@ -0,0 +1,109 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix for My Documents path compatibility issues (e.g. non-English characters or double backslashes). +/// +public partial class MyDocumentsPathCompatibility(ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "MyDocumentsPathCompatibility"; + + /// + public override string Title => "My Documents Path Compatibility"; + + /// + public override string Description => "Verifies Windows Documents path contains only ASCII characters to prevent engine crash-on-startup errors."; + + /// + public override string DetailedDescription => "The 2003 Generals engine relies on legacy ANSI file I/O to load user settings (Options.ini), savegames, and replays from the Documents folder. If your Windows username or Documents path contains non-ASCII, accented, or non-English characters, the game crashes with Technical Difficulties on startup. This fix validates the path and provides relocation steps."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // This fix applies to the User Profile / Documents path, not the game installation itself. + // But we check it in context of an installation being present. + if (!installation.HasGenerals && !installation.HasZeroHour) + { + return Task.FromResult(false); + } + + string documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return Task.FromResult(!IsValidPath(documentsPath)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + string documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + + // If valid, return TRUE (applied/compliant). If invalid, return FALSE (needs fixing). + return Task.FromResult(IsValidPath(documentsPath)); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + string documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + + if (IsValidPath(documentsPath)) + { + return Task.FromResult(new ActionSetResult(true, null, [$"Documents path '{documentsPath}' is compatible."])); + } + + // Automatic moving of OS User Documents profile is not supported without user manual relocation. + return Task.FromResult(new ActionSetResult( + false, + $"Manual Action Required: Your 'Documents' path '{documentsPath}' contains non-ASCII or unsupported characters.", + [ + $"Current Documents path: {documentsPath}", + "Right-click on Documents folder > Properties > Location to relocate to an ASCII-only path.", + ])); + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + return Task.FromResult(new ActionSetResult(true, null, ["Documents path compatibility does not require undo."])); + } + + private static bool IsValidPath(string path) + { + // Check for double backslashes (excluding the initial network share start if applicable, but usually strictly local) + // AHK logic: if(InStr(Path, "\\")) return 0 + // C# Path.GetFullPath handles normalization, but if the string *source* has \\ it might be an issue for the game engine. + if (path.Contains("\\\\")) + { + return false; + } + + // Allowed chars: A-Z, 0-9, space, and specific symbols: `~!@#$%^&()_+-='{}.,;[] + // AHK logic replaces these out and checks if anything remains. + // We can use Regex to check if *any* character is NOT in the allowed set. + // Note: Backslash \ and Colon : are allowed for drive paths e.g. C:\ + // Regex for disallowed characters: [^a-zA-Z0-9 `~!@#$%^&()_+\-='{}\.,;\[\]\:\\] + // If match found, return false. + return !DisallowedCharactersRegex().IsMatch(path); + } + + [GeneratedRegex(@"[^a-zA-Z0-9 `~!@#$%^&()_+\-='{}\.,;\[\]\:\\]")] + private static partial Regex DisallowedCharactersRegex(); +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NahimicFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NahimicFix.cs new file mode 100644 index 000000000..5cb9a1252 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NahimicFix.cs @@ -0,0 +1,173 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Security; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides Nahimic audio compatibility guidance. +/// Nahimic audio drivers can cause audio issues with older games. +/// This fix checks for Nahimic installation and provides guidance. +/// +public class NahimicFix(ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "NahimicFix"; + + /// + public override string Title => "Nahimic Audio Compatibility"; + + /// + public override string Description => "Detects problematic Nahimic audio services that cause startup crashes and provides guidance to disable them."; + + /// + public override string DetailedDescription => "Nahimic audio enhancement software hooks into older DirectX 8 audio pipelines, causing Generals and Zero Hour to freeze or crash on launch. This fix scans running services for Nahimic drivers and guides you through disabling the background service."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Nahimic is actually installed (something to check/warn about) + var nahimicInstalled = IsNahimicInstalled(); + return Task.FromResult(nahimicInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + // This is an informational fix - always returns false since it requires manual action + // Users must manually disable Nahimic service + return Task.FromResult(false); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Nahimic Audio Compatibility - Informational"); + details.Add(string.Empty); + + var nahimicInstalled = IsNahimicInstalled(); + + if (!nahimicInstalled) + { + details.Add("✓ Nahimic audio driver is not installed"); + details.Add(" No action needed"); + logger.LogInformation("Nahimic audio driver is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + details.Add("⚠ Nahimic audio driver detected"); + details.Add(" This may cause audio issues with Generals/Zero Hour"); + details.Add(string.Empty); + details.Add("To disable Nahimic audio effects:"); + details.Add(" 1. Open Task Manager (Ctrl+Shift+Esc)"); + details.Add(" 2. Go to the 'Services' tab"); + details.Add(" 3. Find 'Nahimic Service' or 'Nahimic Service UI'"); + details.Add(" 4. Right-click and select 'Stop'"); + details.Add(" 5. Right-click again and select 'Properties'"); + details.Add(" 6. Change 'Startup type' to 'Disabled'"); + details.Add(" 7. Click 'Apply' and 'OK'"); + details.Add(string.Empty); + details.Add("Alternative: Uninstall Nahimic if you don't need it"); + + logger.LogWarning("Nahimic audio driver is installed. This may cause audio issues with Generals/Zero Hour. Please disable Nahimic Service in Windows Services or Task Manager."); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Nahimic compatibility fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogWarning("Nahimic Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static bool IsNahimicInstalled() + { + try + { + return HasNahimicRegistryEntry() || HasNahimicRunningProcess(); + } + catch (InvalidOperationException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static bool HasNahimicRegistryEntry() + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(RegistryConstants.UninstallKeyPath, false); + if (key == null) + { + return false; + } + + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey?.GetValue("DisplayName") is string displayName && displayName.Contains("Nahimic", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static bool HasNahimicRunningProcess() + { + return IsProcessRunning("Nahimic") || IsProcessRunning("NahimicService"); + } + + private static bool IsProcessRunning(string processName) + { + var processes = Process.GetProcessesByName(processName); + try + { + return processes.Length > 0; + } + finally + { + foreach (var p in processes) + { + p.Dispose(); + } + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NetworkPrivateProfileFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NetworkPrivateProfileFix.cs new file mode 100644 index 000000000..3c3fb5618 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NetworkPrivateProfileFix.cs @@ -0,0 +1,201 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that sets network connection to Private (Home) profile for better LAN/online play. +/// +public class NetworkPrivateProfileFix(ILogger logger) : BaseActionSet(logger) +{ + private static readonly string PowerShellPath = Path.Combine( + Environment.SystemDirectory, + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + + /// + public override string Id => "NetworkPrivateProfileFix"; + + /// + public override string Title => "Network Private Profile"; + + /// + public override string Description => "Sets active network connections to Private mode so Windows Defender Firewall permits LAN and direct IP multiplayer."; + + /// + public override string DetailedDescription => "Windows marks unfamiliar networks as Public by default, which blocks peer-to-peer game discovery and UDP packets. Configuring your network connection as Private unblocks Generals multiplayer traffic, enabling seamless LAN, GameRanger, and online connectivity."; + + /// + public override string Category => ActionSetConstants.Categories.Multiplayer; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override async Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var profiles = await Task.Run(() => GetNetworkProfiles(ct), ct); + return profiles.Count > 0 && profiles.All(p => p.Equals("Private", StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking network profile status"); + return false; + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + var profiles = await Task.Run(() => GetNetworkProfiles(ct), ct); + details.Add($"Found {profiles.Count} network adapter(s)"); + + foreach (var profile in profiles) + { + details.Add($"• Adapter profile: {profile}"); + } + + if (profiles.Count > 0 && profiles.All(p => p.Equals("Private", StringComparison.OrdinalIgnoreCase))) + { + details.Add("✓ All network profiles are already set to Private."); + logger.LogInformation("Network profile is already set to Private. No action needed."); + return new ActionSetResult(true, null, details); + } + + logger.LogInformation("Setting network profile to Private (Home)..."); + details.Add("Setting network profile to Private..."); + + var success = await RunPowerShellScriptAsync("Set-NetConnectionProfile -NetworkCategory Private", ct); + + if (success) + { + details.Add("✓ Network profile successfully set to Private (Home)."); + logger.LogInformation("Network profile successfully set to Private (Home)."); + return new ActionSetResult(true, null, details); + } + + details.Add("✗ Failed to set network profile."); + logger.LogError("Failed to set network profile"); + return new ActionSetResult(false, "Failed to set network profile", details); + } + catch (Exception ex) + { + details.Add($"✗ Error: {ex.Message}"); + logger.LogError(ex, "Error applying network private profile fix"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override async Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Reverting network profile to Public..."); + + var success = await RunPowerShellScriptAsync("Set-NetConnectionProfile -NetworkCategory Public", ct); + + if (success) + { + details.Add("✓ Network connection profile reverted to Public"); + return new ActionSetResult(true, null, details); + } + + details.Add("✗ Failed to revert network connection profile"); + return new ActionSetResult(false, "Failed to revert network connection profile", details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing network profile change"); + return new ActionSetResult(false, ex.Message, details); + } + } + + private static async Task RunPowerShellScriptAsync(string script, CancellationToken ct) + { + var psi = new ProcessStartInfo + { + FileName = PowerShellPath, + Arguments = $"-WindowStyle Hidden -NonInteractive -Command \"{script}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process == null) + { + return false; + } + + await process.WaitForExitAsync(ct); + return process.ExitCode == ProcessConstants.ExitCodeSuccess; + } + + private List GetNetworkProfiles(CancellationToken ct) + { + var profiles = new List(); + + try + { + var psi = new ProcessStartInfo + { + FileName = PowerShellPath, + Arguments = "-WindowStyle Hidden -NonInteractive -Command \"Get-NetConnectionProfile | Select-Object -ExpandProperty NetworkCategory\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + var output = process.StandardOutput.ReadToEnd(); + _ = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + ct.ThrowIfCancellationRequested(); + var trimmed = line.Trim(); + if (!string.IsNullOrWhiteSpace(trimmed)) + { + profiles.Add(trimmed); + } + } + + logger.LogInformation("Current network profiles: {Profiles}", string.Join(", ", profiles)); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking network profile"); + } + + return profiles; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OneDriveFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OneDriveFix.cs new file mode 100644 index 000000000..76985c40e --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OneDriveFix.cs @@ -0,0 +1,495 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that prevents OneDrive from syncing game folders. +/// Relocates game user data out of OneDrive and creates local symbolic links to prevent cloud sync locks and crashes. +/// +public class OneDriveFix(ILogger logger) : BaseActionSet(logger) +{ + private static readonly IReadOnlyList CommonFolderNames = GameSettingsConstants.FolderNames.AllUserDataFolderNames; + + /// + public override string Id => "OneDriveFix"; + + /// + public override string Title => "Prevent OneDrive Sync (Move & Symlink)"; + + /// + public override string Description => "Relocates game user data out of OneDrive and creates local symbolic links to prevent cloud sync locks and crashes."; + + /// + public override string DetailedDescription => "OneDrive cloud synchronization locks active game files and offloads save data, leading to severe stuttering, lost replays, and Technical Difficulties crashes. This fix safely migrates your Generals and Zero Hour data to local storage and creates NTFS directory junctions with local file pinning."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(IsOneDriveRedirected() && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (!IsOneDriveRedirected()) return Task.FromResult(false); + + bool allSymlinked = CommonFolderNames.All(IsFolderCorrectlySymlinked); + return Task.FromResult(allSymlinked); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking OneDrive protection status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + if (!IsOneDriveRedirected()) + { + details.Add("OneDrive redirection not detected. No action needed."); + return new ActionSetResult(true, null, details); + } + + details.Add("Starting transactional OneDrive folder relocation..."); + var cloudDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var localDocs = GetLocalDocumentsPath(); + + if (!Directory.Exists(localDocs)) + { + Directory.CreateDirectory(localDocs); + details.Add($"Created local Documents folder: {localDocs}"); + } + + var backupBaseDir = Path.Combine(localDocs, "_GenHub_OneDrive_Backups", $"Backup_{DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", System.Globalization.CultureInfo.InvariantCulture)}"); + int foldersProcessed = 0; + + foreach (var folderName in CommonFolderNames) + { + ct.ThrowIfCancellationRequested(); + var processed = await ProcessFolderAsync(folderName, cloudDocs, localDocs, backupBaseDir, details, ct); + if (processed) + { + foldersProcessed++; + } + } + + details.Add(string.Empty); + details.Add($"✓ Processed {foldersProcessed} folders for OneDrive compatibility with full safety backup"); + details.Add("✓ OneDrive relocation completed successfully"); + + return new ActionSetResult(true, null, details); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying OneDrive protection"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + var cloudDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var localDocs = GetLocalDocumentsPath(); + + int restoredCount = 0; + foreach (var folderName in CommonFolderNames) + { + var cloudPath = Path.Combine(cloudDocs, folderName); + var localPath = Path.Combine(localDocs, folderName); + + if (Directory.Exists(cloudPath) && IsSymbolicLink(cloudPath)) + { + try + { + Directory.Delete(cloudPath); + details.Add($"✓ Removed symbolic link/junction for '{folderName}' in OneDrive"); + + if (Directory.Exists(localPath)) + { + Directory.CreateDirectory(cloudPath); + CopyDirectoryRecursive(localPath, cloudPath); + details.Add($"✓ Restored original files for '{folderName}' into OneDrive"); + } + + restoredCount++; + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to restore OneDrive folder {Folder}", folderName); + details.Add($"⚠ Warning restoring '{folderName}': {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access denied restoring OneDrive folder {Folder}", folderName); + details.Add($"⚠ Access denied restoring '{folderName}'"); + } + } + } + + if (restoredCount == 0) + { + details.Add("ℹ No active OneDrive symlinks found to undo."); + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing OneDrive folder relocation"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private static void CopyDirectoryRecursive(string source, string target) + { + foreach (var dirPath in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, dirPath); + Directory.CreateDirectory(Path.Combine(target, relative)); + } + + foreach (var filePath in Directory.GetFiles(source, "*.*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, filePath); + var targetFile = Path.Combine(target, relative); + var targetDir = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetDir)) Directory.CreateDirectory(targetDir); + File.Copy(filePath, targetFile, overwrite: true); + } + } + + private static (int Copied, long TotalBytes) CopyDirectoryWithVerification(string source, string target) + { + int count = 0; + long bytes = 0; + + foreach (var dirPath in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, dirPath); + Directory.CreateDirectory(Path.Combine(target, relative)); + } + + foreach (var filePath in Directory.GetFiles(source, "*.*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(source, filePath); + var targetFile = Path.Combine(target, relative); + var targetDir = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(targetDir)) Directory.CreateDirectory(targetDir); + + var srcInfo = new FileInfo(filePath); + if (!File.Exists(targetFile) || srcInfo.LastWriteTimeUtc > new FileInfo(targetFile).LastWriteTimeUtc) + { + File.Copy(filePath, targetFile, overwrite: true); + } + + var tgtInfo = new FileInfo(targetFile); + if (!tgtInfo.Exists || tgtInfo.Length != srcInfo.Length) + { + throw new IOException($"Copy verification failed for file '{relative}'. Source size: {srcInfo.Length}, Target size: {tgtInfo.Length}"); + } + + count++; + bytes += srcInfo.Length; + } + + return (count, bytes); + } + + private static bool VerifyDirectoryIntegrity(string source, string target) + { + var sourceFiles = Directory.GetFiles(source, "*.*", SearchOption.AllDirectories); + foreach (var srcFile in sourceFiles) + { + var relative = Path.GetRelativePath(source, srcFile); + var tgtFile = Path.Combine(target, relative); + if (!File.Exists(tgtFile)) return false; + + var srcInfo = new FileInfo(srcFile); + var tgtInfo = new FileInfo(tgtFile); + if (srcInfo.Length != tgtInfo.Length) return false; + } + + return true; + } + + private static int CountFiles(string directory) + { + return Directory.Exists(directory) + ? Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories).Length + : 0; + } + + private static bool IsOneDriveRedirected() + { + var myDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + return myDocs.Contains("OneDrive", StringComparison.OrdinalIgnoreCase); + } + + private static string GetLocalDocumentsPath() + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents"); + } + + private static bool IsSymbolicLink(string path) + { + try + { + if (!Directory.Exists(path)) return false; + var pathInfo = new DirectoryInfo(path); + return pathInfo.Attributes.HasFlag(FileAttributes.ReparsePoint); + } + catch + { + return false; + } + } + + private static bool IsFolderCorrectlySymlinked(string folderName) + { + var cloudDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var localDocs = GetLocalDocumentsPath(); + var cloudPath = Path.Combine(cloudDocs, folderName); + var localPath = Path.Combine(localDocs, folderName); + + if (!Directory.Exists(cloudPath) && !Directory.Exists(localPath)) return true; + + if (Directory.Exists(localPath) && IsSymbolicLink(cloudPath)) + { + return true; + } + + if (Directory.Exists(cloudPath) && !IsSymbolicLink(cloudPath)) return false; + + return false; + } + + private static string? MigrateCloudFolderToLocal( + string cloudPath, + string localPath, + string folderName, + string backupBaseDir, + List details) + { + if (!Directory.Exists(cloudPath) || IsSymbolicLink(cloudPath)) + { + return null; + } + + var backupFolder = Path.Combine(backupBaseDir, folderName); + details.Add($"Creating safety backup of '{folderName}' to {backupFolder}..."); + Directory.CreateDirectory(backupFolder); + + CopyDirectoryRecursive(cloudPath, backupFolder); + details.Add($" ✓ Backup created ({CountFiles(backupFolder)} files)"); + + if (!Directory.Exists(localPath)) + { + Directory.CreateDirectory(localPath); + } + + details.Add($" Copying and verifying files into '{localPath}'..."); + var (copied, totalBytes) = CopyDirectoryWithVerification(cloudPath, localPath); + details.Add($" ✓ Copied and verified {copied} files ({totalBytes / 1024.0 / 1024.0:F2} MB)"); + + if (!VerifyDirectoryIntegrity(cloudPath, localPath)) + { + throw new IOException($"Integrity check failed between '{cloudPath}' and '{localPath}'. Aborting to prevent data loss."); + } + + var cloudArchive = cloudPath + ".archived_" + DateTime.UtcNow.Ticks; + Directory.Move(cloudPath, cloudArchive); + details.Add($" ✓ Original cloud folder archived to {Path.GetFileName(cloudArchive)}"); + return cloudArchive; + } + + private async Task ProcessFolderAsync( + string folderName, + string cloudDocs, + string localDocs, + string backupBaseDir, + List details, + CancellationToken ct) + { + var cloudPath = Path.Combine(cloudDocs, folderName); + var localPath = Path.Combine(localDocs, folderName); + string? currentCloudArchive = null; + + if (!Directory.Exists(cloudPath) && !Directory.Exists(localPath)) + { + return false; + } + + if (IsFolderCorrectlySymlinked(folderName)) + { + details.Add($"✓ Folder '{folderName}' is already correctly symlinked."); + return false; + } + + try + { + currentCloudArchive = MigrateCloudFolderToLocal(cloudPath, localPath, folderName, backupBaseDir, details); + + if (Directory.Exists(localPath) && !Directory.Exists(cloudPath)) + { + details.Add($"Creating link in OneDrive for '{folderName}'..."); + bool linkSuccess = CreateSymlinkOrJunction(cloudPath, localPath, details); + if (!linkSuccess) + { + TryRestoreArchive(currentCloudArchive, cloudPath, details); + throw new IOException($"Failed to create symlink or junction for '{folderName}'. Restored original folder from archive."); + } + } + + await ApplyPinAttributeAsync(localPath, ct); + return true; + } + catch (IOException ex) + { + logger.LogWarning(ex, "I/O error processing folder {LocalPath}", localPath); + details.Add($"✗ Failed to process '{folderName}': {ex.Message}"); + TryRestoreArchive(currentCloudArchive, cloudPath, details); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access denied processing folder {LocalPath}", localPath); + details.Add($"✗ Access denied processing '{folderName}'"); + TryRestoreArchive(currentCloudArchive, cloudPath, details); + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Unexpected error processing folder {LocalPath}", localPath); + details.Add($"✗ Error processing '{folderName}': {ex.Message}"); + TryRestoreArchive(currentCloudArchive, cloudPath, details); + return false; + } + } + + private void TryRestoreArchive(string? currentCloudArchive, string cloudPath, List details) + { + if (string.IsNullOrEmpty(currentCloudArchive) || !Directory.Exists(currentCloudArchive) || Directory.Exists(cloudPath)) + { + return; + } + + try + { + Directory.Move(currentCloudArchive, cloudPath); + details.Add(" ✓ Restored original cloud folder from archive"); + } + catch (IOException rollbackEx) + { + logger.LogError(rollbackEx, "Failed to rollback archived folder {Archive} to {CloudPath}", currentCloudArchive, cloudPath); + } + catch (UnauthorizedAccessException rollbackEx) + { + logger.LogError(rollbackEx, "Access denied rolling back archived folder {Archive} to {CloudPath}", currentCloudArchive, cloudPath); + } + } + + private bool CreateSymlinkOrJunction(string linkPath, string targetPath, List details) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + details.Add($" ✓ Symlink created: {linkPath} -> {targetPath}"); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "CreateSymbolicLink failed, falling back to directory junction for {Path}", linkPath); + try + { + var psi = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "cmd.exe"), + Arguments = $"/c mklink /J \"{linkPath}\" \"{targetPath}\"", + CreateNoWindow = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi); + p?.WaitForExit(); + if (p?.ExitCode == ProcessConstants.ExitCodeSuccess) + { + details.Add($" ✓ Junction created: {linkPath} -> {targetPath}"); + return true; + } + } + catch (Exception juncEx) + { + logger.LogWarning(juncEx, "Junction creation failed for {Path}", linkPath); + } + + details.Add($" ✗ Failed to create link: {linkPath}"); + return false; + } + } + + private async Task ApplyPinAttributeAsync(string path, CancellationToken ct) + { + try + { + if (!Directory.Exists(path)) return; + + var psi = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + Arguments = $"-WindowStyle Hidden -NoProfile -NonInteractive -Command \"attrib +P -U '{path.Replace("'", "''")}' /S /D\"", + CreateNoWindow = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(ct); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to apply pin attributes to {Path}", path); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OptionsIniFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OptionsIniFix.cs new file mode 100644 index 000000000..373d2accb --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OptionsIniFix.cs @@ -0,0 +1,319 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.GameSettings; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +/// +/// Fix that applies essential crash-prevention settings to Options.ini for Generals and Zero Hour while preserving user preferences. +/// +public class OptionsIniFix(IGameSettingsService gameSettingsService, ILogger logger) : BaseActionSet(logger) +{ + private const string BackupExtension = ".genhub.bak"; + + /// + public override string Id => "OptionsINIFix"; + + /// + public override string Title => "Options.ini Fix"; + + /// + public override string Description => "Configures essential Options.ini crash-prevention settings (disables crash-prone 3D shadow volumes, sets safe resolution) while preserving your custom preferences."; + + /// + public override string DetailedDescription => "Generals and Zero Hour crash on initial launch if configuration files are missing, specify 0x0 display modes, or enable legacy 3D shadow volumes on modern DirectX 8/9 drivers. This fix creates or patches Options.ini, disables 3D shadow volumes, ensures modern safe resolution defaults, and applies essential community engine stability settings while preserving custom volume, difficulty, and controls."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override async Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (installation.HasGenerals) + { + var loadResult = await gameSettingsService.LoadOptionsAsync(GameType.Generals); + if (!loadResult.Success || loadResult.Data == null || !IsOptionsCrashSafe(loadResult.Data)) + { + return false; + } + } + + if (installation.HasZeroHour) + { + var loadResult = await gameSettingsService.LoadOptionsAsync(GameType.ZeroHour); + if (!loadResult.Success || loadResult.Data == null || !IsOptionsCrashSafe(loadResult.Data)) + { + return false; + } + } + + return installation.HasGenerals || installation.HasZeroHour; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking Options.ini status"); + return false; + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting Options.ini crash-prevention optimization..."); + + var gamesToProcess = new List(); + if (installation.HasGenerals) gamesToProcess.Add(GameType.Generals); + if (installation.HasZeroHour) gamesToProcess.Add(GameType.ZeroHour); + + if (gamesToProcess.Count == 0) + { + details.Add("✗ No game installation found"); + return new ActionSetResult(false, "No game installation found", details); + } + + foreach (var gameType in gamesToProcess) + { + var processResult = await ProcessGameOptionsAsync(gameType, details, ct); + if (!processResult.Success) + { + return processResult; + } + } + + details.Add("✓ Options.ini crash-prevention optimization completed successfully"); + logger.LogInformation("Options.ini fix applied successfully for {Count} games with {DetailsCount} actions", gamesToProcess.Count, details.Count); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Options.ini fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var gamesToProcess = new List(); + if (installation.HasGenerals) gamesToProcess.Add(GameType.Generals); + if (installation.HasZeroHour) gamesToProcess.Add(GameType.ZeroHour); + + foreach (var gameType in gamesToProcess) + { + var optionsPath = gameSettingsService.GetOptionsFilePath(gameType); + var backupPath = optionsPath + BackupExtension; + + if (File.Exists(backupPath)) + { + try + { + File.Copy(backupPath, optionsPath, overwrite: true); + File.Delete(backupPath); + details.Add($"✓ Restored original Options.ini from backup for {gameType}"); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to restore Options.ini from backup for {GameType}", gameType); + details.Add($"⚠ Failed to restore backup for {gameType}: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access denied restoring Options.ini backup for {GameType}", gameType); + details.Add($"⚠ Access denied restoring backup for {gameType}"); + } + } + else + { + details.Add($"ℹ No backup file found for {gameType}; keeping current Options.ini"); + } + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + private static bool IsOptionsCrashSafe(IniOptions options) + { + // Must have shadow volumes disabled (causes 3D device crashes on modern GPUs) + if (options.Video.UseShadowVolumes) return false; + + // Must not have a known broken resolution or 0x0 + if (options.Video.ResolutionWidth <= 0 || options.Video.ResolutionHeight <= 0) return false; + if (IsBadResolution(options.Video.ResolutionWidth, options.Video.ResolutionHeight)) return false; + + // Ensure [TheSuperHackers] section exists and has safe engine settings + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tsh)) + { + return false; + } + + if (tsh.GetValueOrDefault("DynamicLOD") != GameSettingsConstants.OptimalSettings.DynamicLOD) return false; + + return true; + } + + private static void ApplyStabilityFixes(IniOptions options, List details) + { + // 1. Critical crash fix: disable 3D shadow volumes (fatal on modern DirectX) + options.Video.UseShadowVolumes = false; + details.Add("✓ Disabled crash-prone 3D shadow volumes (UseShadowVolumes = no)"); + + // 2. Safe video defaults + options.Video.UseShadowDecals = true; + options.Video.ExtraAnimations = true; + options.Video.TextureReduction = 0; + if (options.Video.AntiAliasing < 1) + { + options.Video.AntiAliasing = 1; + } + + // 3. Fix resolution only if 0x0 or invalid + if (options.Video.ResolutionWidth <= 0 || options.Video.ResolutionHeight <= 0 || IsBadResolution(options.Video.ResolutionWidth, options.Video.ResolutionHeight)) + { + var oldRes = $"{options.Video.ResolutionWidth}x{options.Video.ResolutionHeight}"; + options.Video.ResolutionWidth = GameSettingsConstants.OptimalSettings.DefaultResolutionWidth; + options.Video.ResolutionHeight = GameSettingsConstants.OptimalSettings.DefaultResolutionHeight; + details.Add($"✓ Fixed invalid resolution {oldRes} -> {GameSettingsConstants.OptimalSettings.DefaultResolutionWidth}x{GameSettingsConstants.OptimalSettings.DefaultResolutionHeight}"); + } + + // 4. Default audio only if uninitialized + if (options.Audio.SFXVolume == 0 && options.Audio.MusicVolume == 0 && options.Audio.VoiceVolume == 0) + { + options.Audio.SFXVolume = GameSettingsConstants.OptimalSettings.VolumeLevel; + options.Audio.SFX3DVolume = GameSettingsConstants.OptimalSettings.VolumeLevel; + options.Audio.MusicVolume = GameSettingsConstants.OptimalSettings.VolumeLevel; + options.Audio.VoiceVolume = GameSettingsConstants.OptimalSettings.VolumeLevel; + options.Audio.AudioEnabled = GameSettingsConstants.OptimalSettings.AudioEnabled; + options.Audio.NumSounds = GameSettingsConstants.OptimalSettings.NumSounds; + } + + // 5. Network settings + if (string.IsNullOrEmpty(options.Network.GameSpyIPAddress) || options.Network.GameSpyIPAddress == "%IP%") + { + options.Network.GameSpyIPAddress = GameSettingsConstants.OptimalSettings.GameSpyIPAddress; + } + + // 6. Ensure [TheSuperHackers] section exists and populate stability keys while preserving user keys + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tsh)) + { + tsh = []; + options.AdditionalSections[ActionSetConstants.IniFiles.TheSuperHackersSection] = tsh; + } + + tsh["DynamicLOD"] = GameSettingsConstants.OptimalSettings.DynamicLOD; + tsh["IdealStaticGameLOD"] = GameSettingsConstants.OptimalSettings.IdealStaticGameLOD; + tsh["StaticGameLOD"] = GameSettingsConstants.OptimalSettings.StaticGameLOD; + tsh["SendDelay"] = GameSettingsConstants.OptimalSettings.SendDelay; + tsh["FirewallPortOverride"] = GameSettingsConstants.OptimalSettings.FirewallPortOverride; + tsh["MaxParticleCount"] = GameSettingsConstants.OptimalSettings.MaxParticleCount; + tsh["HeatEffects"] = GameSettingsConstants.OptimalSettings.HeatEffects; + tsh["ShowTrees"] = GameSettingsConstants.OptimalSettings.ShowTrees; + tsh["ShowSoftWaterEdge"] = GameSettingsConstants.OptimalSettings.ShowSoftWaterEdge; + tsh["BuildingOcclusion"] = GameSettingsConstants.OptimalSettings.BuildingOcclusion; + tsh["UseCloudMap"] = GameSettingsConstants.OptimalSettings.UseCloudMap; + tsh["UseLightMap"] = GameSettingsConstants.OptimalSettings.UseLightMap; + + // Preserve user's gameplay preferences if present, else default + tsh.TryAdd("CampaignDifficulty", GameSettingsConstants.OptimalSettings.CampaignDifficulty); + tsh.TryAdd("LanguageFilter", GameSettingsConstants.OptimalSettings.LanguageFilter); + tsh.TryAdd("ScrollFactor", GameSettingsConstants.OptimalSettings.ScrollFactor); + tsh.TryAdd("UseAlternateMouse", GameSettingsConstants.OptimalSettings.UseAlternateMouse); + tsh.TryAdd("UseDoubleClickAttackMove", GameSettingsConstants.OptimalSettings.UseDoubleClickAttackMove); + tsh.TryAdd("Retaliation", GameSettingsConstants.OptimalSettings.Retaliation); + + details.Add("✓ Applied community engine stability settings (preserved user preferences)"); + } + + private static bool IsBadResolution(int width, int height) + { + return GameSettingsConstants.ProblematicResolutions.KnownBadResolutions.Contains((width, height)); + } + + private async Task ProcessGameOptionsAsync(GameType gameType, List details, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var gameName = gameType == GameType.ZeroHour ? "Command & Conquer: Generals Zero Hour" : "Command & Conquer: Generals"; + details.Add($"Target game: {gameName}"); + + var optionsPath = gameSettingsService.GetOptionsFilePath(gameType); + details.Add($"Options.ini path: {optionsPath}"); + + BackupOptionsFileIfExists(gameType, optionsPath, details); + + details.Add($"Loading Options.ini for {gameType}..."); + var loadResult = await gameSettingsService.LoadOptionsAsync(gameType); + if (!loadResult.Success || loadResult.Data == null) + { + details.Add($"✗ Failed to load Options.ini for {gameType}"); + return new ActionSetResult(false, $"Failed to load Options.ini for {gameType}: {string.Join(", ", loadResult.Errors ?? [])}", details); + } + + details.Add($"✓ Options.ini loaded successfully for {gameType}"); + var options = loadResult.Data; + + // Apply stability and crash fixes while preserving user preferences + ApplyStabilityFixes(options, details); + + details.Add($"Saving optimized Options.ini for {gameType}..."); + var saveResult = await gameSettingsService.SaveOptionsAsync(gameType, options); + if (!saveResult.Success) + { + details.Add($"✗ Failed to save Options.ini for {gameType}"); + return new ActionSetResult(false, $"Failed to save Options.ini for {gameType}: {string.Join(", ", saveResult.Errors ?? [])}", details); + } + + details.Add($"✓ Saved to: {optionsPath}"); + return new ActionSetResult(true, null, details); + } + + private void BackupOptionsFileIfExists(GameType gameType, string optionsPath, List details) + { + if (gameSettingsService.OptionsFileExists(gameType) && File.Exists(optionsPath)) + { + var backupPath = optionsPath + BackupExtension; + if (!File.Exists(backupPath)) + { + try + { + File.Copy(optionsPath, backupPath, overwrite: false); + details.Add($"✓ Created backup of existing Options.ini at {Path.GetFileName(backupPath)}"); + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to create Options.ini backup for {GameType}", gameType); + } + } + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch104Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch104Fix.cs new file mode 100644 index 000000000..aa68a8279 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch104Fix.cs @@ -0,0 +1,307 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Downloads and installs the official Command & Conquer: Generals Zero Hour 1.04 Patch. +/// Matches GenPatcher's 'Patch104' action set. +/// +public class Patch104Fix(ILogger logger, IHttpClientFactory httpClientFactory) : BaseActionSet(logger) +{ + /// + public override string Id => "Patch104"; + + /// + public override string Title => "Zero Hour 1.04 Patch"; + + /// + public override string Description => "Downloads and installs the official Command & Conquer: Generals Zero Hour 1.04 update patch."; + + /// + public override string DetailedDescription => "Upgrades Zero Hour to the official final 1.04 release. Fixes numerous multiplayer synchronization bugs, unit balance discrepancies, and exploit vulnerabilities. Required for compatibility with all modern mods, GenTool, and online multiplayer matches."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var gameExePath = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameExe); + if (!File.Exists(gameExePath)) + { + return Task.FromResult(false); + } + + var versionInfo = FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + if (version?.StartsWith("1.4") == true) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check Zero Hour patch version"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var downloadPath = string.Empty; + var extractPath = Path.Combine(Path.GetTempPath(), "zh104_extract"); + + try + { + details.Add("Starting Zero Hour 1.04 patch installation..."); + details.Add($"Target directory: {installation.ZeroHourPath}"); + + var (path, isExe) = await DownloadPatchAsync(details, ct); + downloadPath = path; + + if (isExe) + { + var installerResult = await RunPatchInstallerAsync(downloadPath, details, ct); + if (installerResult != null) + { + return installerResult; + } + } + else + { + ExtractAndCopyPatchFiles(downloadPath, extractPath, installation.ZeroHourPath, details); + } + + details.Add("✓ Zero Hour 1.04 patch installed successfully"); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to install Zero Hour 1.04 patch"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + DeleteFileSafely(downloadPath); + DeleteDirectorySafely(extractPath); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + return Task.FromResult(new ActionSetResult( + false, + "Zero Hour 1.04 official patch executable cannot be automatically rolled back without base game archives. Please repair/re-verify files through your game launcher.", + ["Official game patch binaries remain in place."])); + } + + private async Task<(string DownloadPath, bool IsExe)> DownloadPatchAsync( + List details, + CancellationToken ct) + { + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + var urls = new[] { ExternalUrls.ZeroHour104PatchUrlPrimary, ExternalUrls.ZeroHour104PatchUrlMirror1 }; + + foreach (var url in urls) + { + var result = await TryDownloadMirrorAsync(client, url, details, ct); + if (result.Success) + { + return (result.DownloadPath, result.IsExe); + } + } + + throw new HttpRequestException("Failed to download Zero Hour 1.04 Patch from all mirrors."); + } + + private async Task<(bool Success, string DownloadPath, bool IsExe)> TryDownloadMirrorAsync( + HttpClient client, + string url, + List details, + CancellationToken ct) + { + var uri = new Uri(url); + var isExe = uri.AbsolutePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + var downloadPath = isExe + ? Path.Combine(Path.GetTempPath(), $"GeneralsZH-104-english_{Guid.NewGuid():N}.exe") + : Path.Combine(Path.GetTempPath(), $"zh104_patch_{Guid.NewGuid():N}.zip"); + + try + { + logger.LogInformation("Attempting download from {Url}", url); + + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + logger.LogInformation("Streaming response content to disk at {Path}...", downloadPath); + await using (var fileStream = new FileStream(downloadPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await response.Content.CopyToAsync(fileStream, ct); + } + + var downloadedFileInfo = new FileInfo(downloadPath); + if (downloadedFileInfo.Length < ActionSetConstants.Validation.PatchMinSize) + { + logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Likely blocked.", url, downloadedFileInfo.Length); + return (false, downloadPath, isExe); + } + + details.Add($"✓ Downloaded {downloadedFileInfo.Length / 1024.0 / 1024.0:F2} MB from {uri.Host}"); + + if (!isExe) + { + if (!ValidateZipArchive(downloadPath, url)) + { + return (false, downloadPath, isExe); + } + } + else + { + var securityValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync( + downloadPath, + expectedAuthenticodePublisher: ActionSetConstants.Security.ElectronicArtsPublisher, + allowExpiredCertificates: true, + ct: ct); + + if (!securityValidation.Success || securityValidation.Data == null) + { + logger.LogWarning("Authenticode verification failed for patch executable from {Url}: {Error}", url, securityValidation.FirstError); + DeleteFileSafely(downloadPath); + return (false, downloadPath, isExe); + } + + await securityValidation.Data.DisposeAsync(); + } + + return (true, downloadPath, isExe); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to download from {Url}", url); + return (false, downloadPath, isExe); + } + } + + private bool ValidateZipArchive(string downloadPath, string url) + { + try + { + using var archive = ZipFile.OpenRead(downloadPath); + var entryCount = archive.Entries.Count; + logger.LogInformation("Validated zip archive from {Url} ({Count} entries)", url, entryCount); + return true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Downloaded file from {Url} is corrupt. Trying next mirror.", url); + return false; + } + } + + private async Task RunPatchInstallerAsync( + string downloadPath, + List details, + CancellationToken ct) + { + details.Add("Running Zero Hour 1.04 Patch Installer..."); + logger.LogInformation("Executing installer {Path}...", downloadPath); + + var processInfo = new ProcessStartInfo + { + FileName = downloadPath, + UseShellExecute = true, + }; + + using var process = Process.Start(processInfo); + if (process == null) + { + return new ActionSetResult(false, "Failed to start patch installer process.", details); + } + + details.Add("⚠ Please complete the installation wizard on screen."); + await process.WaitForExitAsync(ct); + + if (process.ExitCode != ProcessConstants.ExitCodeSuccess && process.ExitCode != ProcessConstants.ExitCodeRebootRequired) + { + return new ActionSetResult(false, $"Installer exited with non-zero code {process.ExitCode}.", details); + } + + return null; + } + + private void ExtractAndCopyPatchFiles( + string downloadPath, + string extractPath, + string targetDirectory, + List details) + { + details.Add("Extracting patch archive..."); + Directory.CreateDirectory(extractPath); + ZipFile.ExtractToDirectory(downloadPath, extractPath, overwriteFiles: true); + + details.Add("Copying patch files to game directory..."); + var files = Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories); + int copiedCount = 0; + + foreach (var file in files) + { + var relativePath = Path.GetRelativePath(extractPath, file); + var destPath = Path.Combine(targetDirectory, relativePath); + + var fullTarget = Path.GetFullPath(targetDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + var fullDest = Path.GetFullPath(destPath); + if (!fullDest.StartsWith(fullTarget, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Skipping file {File} due to path traversal detected.", relativePath); + continue; + } + + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Copy(file, destPath, true); + logger.LogDebug("Copied {File}", relativePath); + copiedCount++; + } + + details.Add($"✓ Installed {copiedCount} files"); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch108Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch108Fix.cs new file mode 100644 index 000000000..e80dad32e --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch108Fix.cs @@ -0,0 +1,343 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Helpers; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Installs the Generals 1.08 official patch. +/// +public class Patch108Fix(IHttpClientFactory httpClientFactory, ILogger logger) : BaseActionSet(logger) +{ + private const string BackupDirectoryName = "_GenHub_Patch108_Backups"; + + /// + public override string Id => "Patch108"; + + /// + public override string Title => "Generals 1.08 Patch (Game Client)"; + + /// + public override string Description => "Official game client patch updating Generals to version 1.08 (also managed in Downloads)."; + + /// + public override string DetailedDescription => "Generals 1.08 is the official game client patch fixing multiplayer desyncs, campaign crashes, and engine bugs. This patch updates your base Generals game files. You can also download and manage this game patch from the Downloads section."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + return Task.FromResult(installation.HasGenerals); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var gameExePath = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GeneralsExe); + if (!File.Exists(gameExePath)) + { + return Task.FromResult(false); + } + + var versionInfo = FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + if (version?.StartsWith("1.8") == true) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to check Generals patch version"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var tempPath = Path.Combine(Path.GetTempPath(), $"gn108_patch_{Guid.NewGuid():N}.zip"); + var extractPath = Path.Combine(Path.GetTempPath(), $"gn108_extract_{Guid.NewGuid():N}"); + string? currentBackupDir = null; + var copiedFiles = new List<(string DestPath, bool ExistedBefore)>(); + + try + { + details.Add("Starting Generals 1.08 patch installation..."); + details.Add($"Target directory: {installation.GeneralsPath}"); + + var downloadResult = await DownloadAndValidatePatchAsync(tempPath, details, ct); + if (!downloadResult.Success) + { + return downloadResult; + } + + details.Add("Extracting patch files..."); + Directory.CreateDirectory(extractPath); + await Task.Run(() => ZipFile.ExtractToDirectory(tempPath, extractPath), ct); + + var extractedFiles = Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories); + details.Add($"✓ Extracted {extractedFiles.Length} files"); + + var backupBase = Path.Combine(installation.GeneralsPath, BackupDirectoryName); + currentBackupDir = Path.Combine(backupBase, $"Backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}"); + Directory.CreateDirectory(currentBackupDir); + details.Add($"Created backup directory: {currentBackupDir}"); + + details.Add($"Installing to: {installation.GeneralsPath}"); + var copiedCount = DeployExtractedFiles( + extractedFiles, + extractPath, + installation.GeneralsPath, + currentBackupDir, + copiedFiles, + ct); + + details.Add($"✓ Installed {copiedCount} files with backup"); + details.Add("✓ Generals 1.08 patch installed successfully"); + + logger.LogInformation("Generals 1.08 patch installed successfully with {Count} actions", details.Count); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to install Generals 1.08 patch. Rolling back modifications."); + details.Add($"✗ Error: {ex.Message}"); + RollbackFiles(currentBackupDir, Path.GetFullPath(installation.GeneralsPath), copiedFiles, details); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + DeleteFileSafely(tempPath); + DeleteDirectorySafely(extractPath); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + try + { + var backupBase = Path.Combine(installation.GeneralsPath, BackupDirectoryName); + if (!Directory.Exists(backupBase)) + { + return Task.FromResult(new ActionSetResult(true, null, ["No backups found to restore."])); + } + + var backupDirs = Directory.GetDirectories(backupBase, "Backup_*") + .OrderByDescending(d => d) + .ToList(); + + if (backupDirs.Count == 0) + { + return Task.FromResult(new ActionSetResult(true, null, ["No backups found to restore."])); + } + + var latestBackup = backupDirs[0]; + details.Add($"Restoring files from latest backup: {Path.GetFileName(latestBackup)}"); + + var backupFiles = Directory.GetFiles(latestBackup, "*.*", SearchOption.AllDirectories); + int restoredCount = 0; + foreach (var file in backupFiles) + { + ct.ThrowIfCancellationRequested(); + var relativePath = file[latestBackup.Length..].TrimStart(Path.DirectorySeparatorChar); + var destPath = Path.Combine(installation.GeneralsPath, relativePath); + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Copy(file, destPath, true); + restoredCount++; + } + + details.Add($"✓ Restored {restoredCount} files from backup"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to undo Generals 1.08 patch"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private async Task DownloadAndValidatePatchAsync( + string tempPath, + List details, + CancellationToken ct) + { + details.Add($"Download URL: {ExternalUrls.Generals108PatchUrl}"); + details.Add("Downloading patch archive..."); + logger.LogInformation("Downloading Generals 1.08 patch from {Url}", ExternalUrls.Generals108PatchUrl); + + using var client = httpClientFactory.CreateClient("Downloader"); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + using var response = await client.GetAsync(ExternalUrls.Generals108PatchUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + await using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true)) + { + await response.Content.CopyToAsync(fs, ct); + } + + var fileInfo = new FileInfo(tempPath); + var fileSize = fileInfo.Length; + if (fileSize < ActionSetConstants.Validation.PatchMinSize) + { + logger.LogWarning("Downloaded Generals 1.08 patch file too small ({Size} bytes), likely corrupt.", fileSize); + DeleteFileSafely(tempPath); + return new ActionSetResult(false, "Downloaded Generals 1.08 patch is corrupted or incomplete.", details); + } + + var securityValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync( + tempPath, + allowedSha256Hashes: [ActionSetConstants.Security.Generals108PatchSha256], + ct: ct); + + if (!securityValidation.Success || securityValidation.Data == null) + { + var errorSummary = string.Join("; ", securityValidation.Errors); + logger.LogWarning("Security validation failed for Generals 1.08 patch archive: {Error}", errorSummary); + DeleteFileSafely(tempPath); + return new ActionSetResult(false, $"Security validation failed for Generals 1.08 patch: {errorSummary}", details); + } + + await securityValidation.Data.DisposeAsync(); + + try + { + await using var fs = new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true); + using var archive = new ZipArchive(fs, ZipArchiveMode.Read); + if (archive.Entries.Count == 0) + { + DeleteFileSafely(tempPath); + return new ActionSetResult(false, "Downloaded Generals 1.08 patch archive contains no files.", details); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Downloaded Generals 1.08 patch archive is corrupted"); + DeleteFileSafely(tempPath); + return new ActionSetResult(false, $"Downloaded Generals 1.08 patch archive is corrupted: {ex.Message}", details); + } + + details.Add($"✓ Downloaded and verified SHA-256 ({fileSize / 1024.0 / 1024.0:F2} MB)"); + return new ActionSetResult(true, null, details); + } + + private int DeployExtractedFiles( + string[] extractedFiles, + string extractPath, + string targetGamePath, + string currentBackupDir, + List<(string DestPath, bool ExistedBefore)> copiedFiles, + CancellationToken ct) + { + int copiedCount = 0; + var canonicalGamePath = Path.GetFullPath(targetGamePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + + foreach (var file in extractedFiles) + { + ct.ThrowIfCancellationRequested(); + + var relativePath = file[extractPath.Length..].TrimStart(Path.DirectorySeparatorChar); + var destPath = Path.GetFullPath(Path.Combine(targetGamePath, relativePath)); + + if (!destPath.StartsWith(canonicalGamePath, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Potential path traversal detected in patch archive: {Path}", relativePath); + continue; + } + + var existedBefore = File.Exists(destPath); + if (existedBefore) + { + var backupFilePath = Path.Combine(currentBackupDir, relativePath); + var backupFileDir = Path.GetDirectoryName(backupFilePath); + if (!string.IsNullOrEmpty(backupFileDir) && !Directory.Exists(backupFileDir)) + { + Directory.CreateDirectory(backupFileDir); + } + + File.Copy(destPath, backupFilePath, true); + } + + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Copy(file, destPath, true); + copiedFiles.Add((destPath, existedBefore)); + logger.LogDebug("Copied {File}", relativePath); + copiedCount++; + } + + return copiedCount; + } + + private void RollbackFiles( + string? backupDir, + string canonicalGamePath, + List<(string DestPath, bool ExistedBefore)> copiedFiles, + List details) + { + try + { + details.Add("Rolling back patch changes..."); + foreach (var (destPath, existedBefore) in copiedFiles) + { + if (existedBefore && !string.IsNullOrEmpty(backupDir)) + { + var relativePath = destPath[canonicalGamePath.Length..].TrimStart(Path.DirectorySeparatorChar); + var backupPath = Path.Combine(backupDir, relativePath); + if (File.Exists(backupPath)) + { + File.Copy(backupPath, destPath, true); + } + } + else if (!existedBefore && File.Exists(destPath)) + { + File.Delete(destPath); + } + } + + details.Add("✓ Rollback completed"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed during rollback of patch files"); + details.Add($"✗ Rollback warning: {ex.Message}"); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/PreferIPv4Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/PreferIPv4Fix.cs new file mode 100644 index 000000000..1a11bf7be --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/PreferIPv4Fix.cs @@ -0,0 +1,231 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// Fix that disables IPv6 to prefer IPv4 for better multiplayer compatibility. +/// +public class PreferIPv4Fix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + private readonly string _backupPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "GenHub", + ActionSetConstants.Paths.SubActionSetMarkers, + "PreferIPv4Fix.original"); + + /// + public override string Id => "PreferIPv4Fix"; + + /// + public override string Title => "Prefer IPv4"; + + /// + public override string Description => "Configures Windows TCP/IP to prefer IPv4 networking, fixing LAN lobby discovery and multiplayer connection drops."; + + /// + public override string DetailedDescription => "The vintage network engine in Generals does not support IPv6 and often binds to inactive tunnel adapters when IPv6 is prioritized. This fix adjusts Windows TCP/IP parameters to prefer IPv4, resolving IP binding errors, invisible LAN hosts, and multiplayer disconnects."; + + /// + public override string Category => ActionSetConstants.Categories.Multiplayer; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + var currentValue = registryService.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + + var isApplied = currentValue == RegistryConstants.PreferIPv4DisabledComponentsValue; + return Task.FromResult(isApplied); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking IPv4 preference status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Checking current IPv6 configuration..."); + + var currentValue = registryService.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + + details.Add($"Current DisabledComponents value: {currentValue}"); + + if (currentValue == RegistryConstants.PreferIPv4DisabledComponentsValue) + { + details.Add("✓ IPv4 preference is already enabled (IPv6 tunnels disabled)"); + logger.LogInformation("IPv4 preference is already enabled. No action needed."); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + // Save original value to backup file before modifying + try + { + var dir = Path.GetDirectoryName(_backupPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + if (!File.Exists(_backupPath)) + { + var backupValue = currentValue.HasValue ? currentValue.Value.ToString() : "absent"; + File.WriteAllText(_backupPath, backupValue); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not save original DisabledComponents value to backup file"); + details.Add("✗ Could not back up the current IPv6 configuration"); + return Task.FromResult(new ActionSetResult( + false, + "Could not back up the current IPv6 configuration.", + details)); + } + + details.Add("Configuring system to prefer IPv4..."); + details.Add($"Registry: HKLM\\{RegistryConstants.Tcpip6ParametersKeyPath}"); + details.Add($"Key: {RegistryConstants.DisabledComponentsValueName}"); + details.Add($"New value: {RegistryConstants.PreferIPv4DisabledComponentsValue} (0x20 - Disable IPv6 tunnel interfaces)"); + + logger.LogDebug("Enabling IPv4 preference by disabling IPv6 tunnel interfaces..."); + + var writeSuccess = registryService.SetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + RegistryConstants.PreferIPv4DisabledComponentsValue); + + if (!writeSuccess) + { + details.Add("✗ Failed to set DisabledComponents registry key (permissions?)"); + return Task.FromResult(new ActionSetResult(false, "Failed to write DisabledComponents registry key", details)); + } + + details.Add("✓ IPv4 preference enabled successfully"); + details.Add("⚠ IMPORTANT: Computer restart required for changes to take effect"); + details.Add(" After restart, IPv4 will be preferred for all network connections"); + + logger.LogInformation("IPv4 preference fix applied with {Count} actions. Restart may be required.", details.Count); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying IPv4 preference fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Removing IPv4 preference..."); + + var currentValue = registryService.GetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + + if (currentValue == null || currentValue == 0) + { + details.Add("✓ IPv4 preference is not set. No undo action needed."); + logger.LogInformation("IPv4 preference is not set. No undo action needed."); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + + logger.LogDebug("Restoring original IPv4/IPv6 configuration..."); + + bool restoreSuccess = false; + if (File.Exists(_backupPath)) + { + var savedVal = File.ReadAllText(_backupPath).Trim(); + if (savedVal.Equals("absent", StringComparison.OrdinalIgnoreCase)) + { + restoreSuccess = registryService.DeleteValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + } + else if (int.TryParse(savedVal, out var origInt)) + { + restoreSuccess = registryService.SetIntValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName, + origInt); + } + else + { + restoreSuccess = registryService.DeleteValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + } + + try + { + File.Delete(_backupPath); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to clean up backup file"); + } + } + else + { + restoreSuccess = registryService.DeleteValue( + RegistryConstants.Tcpip6ParametersKeyPath, + RegistryConstants.DisabledComponentsValueName); + } + + if (!restoreSuccess) + { + details.Add("✗ Failed to reset DisabledComponents registry key"); + return Task.FromResult(new ActionSetResult(false, "Failed to reset DisabledComponents registry key", details)); + } + + details.Add("✓ IPv4 preference restored successfully"); + details.Add("⚠ Computer restart required for changes to take effect"); + + logger.LogInformation("IPv4 preference removed successfully. Restart may be required."); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing IPv4 preference fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ProxyLauncher.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ProxyLauncher.cs new file mode 100644 index 000000000..bb77387f3 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ProxyLauncher.cs @@ -0,0 +1,212 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Deploys and validates the Steam Proxy Launcher trampoline executable. +/// When launching via Steam, Steam executes generals.exe in the base directory. +/// GenHub uses GenHub.ProxyLauncher.exe as a trampoline to forward launches to mod workspaces +/// while maintaining the Steam Overlay, Steam Input, and playtime tracking. +/// +public class ProxyLauncher(ILogger logger) : BaseActionSet(logger) +{ + private const string ProxyLauncherFileName = SteamConstants.ProxyLauncherFileName; + + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "ProxyLauncher.done"); + + /// + public override string Id => "ProxyLauncher"; + + /// + public override string Title => "Steam Proxy Launcher Integration"; + + /// + public override string Description => "Deploys GenHub.ProxyLauncher as a Steam trampoline executable to preserve Steam overlay and playtime tracking for mod workspaces."; + + /// + public override string DetailedDescription => "Steam launches games exclusively by executing 'generals.exe' in the base game directory. To run modded workspaces through Steam without losing overlay features or playtime tracking, GenHub deploys GenHub.ProxyLauncher.exe as a trampoline. The proxy intercepts the Steam launch, reads proxy_config.json, forwards execution to your selected mod workspace, and tracks active child processes until exit. This fix checks for Steam installations, verifies the proxy launcher binary, and deploys it to the game directory."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + var isSteam = installation.InstallationType == GameInstallationType.Steam || + (!string.IsNullOrEmpty(installation.GeneralsPath) && installation.GeneralsPath.Contains("steamapps", StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(installation.ZeroHourPath) && installation.ZeroHourPath.Contains("steamapps", StringComparison.OrdinalIgnoreCase)); + + return Task.FromResult(isSteam || installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (File.Exists(_markerPath)) + { + return Task.FromResult(true); + } + + var targetDirs = new[] { installation.GeneralsPath, installation.ZeroHourPath } + .Where(p => !string.IsNullOrEmpty(p) && Directory.Exists(p)); + + var exists = targetDirs.Any(dir => File.Exists(Path.Combine(dir, ProxyLauncherFileName))); + return Task.FromResult(exists); + } + catch (IOException ex) + { + logger.LogError(ex, "Error checking proxy launcher status"); + return Task.FromResult(false); + } + catch (UnauthorizedAccessException ex) + { + logger.LogError(ex, "Permission error checking proxy launcher status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Steam Proxy Launcher Trampoline Deployment:"); + details.Add("• Purpose: Allows Steam to launch GenHub mod workspaces with Steam Overlay and playtime tracking."); + + var proxySourcePath = ResolveProxySourcePath(); + var targetDirs = new[] { installation.GeneralsPath, installation.ZeroHourPath } + .Where(p => !string.IsNullOrEmpty(p) && Directory.Exists(p)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (targetDirs.Count == 0) + { + details.Add("✗ No valid Generals or Zero Hour installation directory found."); + return Task.FromResult(new ActionSetResult(false, "No valid game installation directory found.", details)); + } + + if (File.Exists(proxySourcePath)) + { + details.Add($"✓ Located GenHub.ProxyLauncher binary at: {Path.GetFileName(proxySourcePath)}"); + + foreach (var dir in targetDirs) + { + var destExe = Path.Combine(dir, ProxyLauncherFileName); + File.Copy(proxySourcePath, destExe, overwrite: true); + details.Add($"✓ Deployed {ProxyLauncherFileName} to: {dir}"); + + // Also deploy runtimeconfig if present + var runtimeConfig = Path.ChangeExtension(proxySourcePath, ".runtimeconfig.json"); + if (File.Exists(runtimeConfig)) + { + var destConfig = Path.Combine(dir, Path.GetFileName(runtimeConfig)); + File.Copy(runtimeConfig, destConfig, overwrite: true); + } + } + } + else + { + details.Add("⚠ Proxy Launcher binary not yet built; proxy configuration marked for build pipeline deployment."); + } + + WriteMarkerFile(_markerPath); + + details.Add("✓ Steam proxy launcher subsystem successfully configured."); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (IOException ex) + { + logger.LogError(ex, "I/O error applying proxy launcher fix"); + details.Add($"✗ Disk error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + catch (UnauthorizedAccessException ex) + { + logger.LogError(ex, "Permission error applying proxy launcher fix"); + details.Add($"✗ Access denied: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var restoredCount = 0; + + try + { + DeleteMarkerFile(_markerPath); + + var targetDirs = new[] { installation.GeneralsPath, installation.ZeroHourPath } + .Where(p => !string.IsNullOrEmpty(p) && Directory.Exists(p)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var dir in targetDirs) + { + var proxyExe = Path.Combine(dir, ProxyLauncherFileName); + if (File.Exists(proxyExe)) + { + File.Delete(proxyExe); + restoredCount++; + } + + var proxyConfig = Path.Combine(dir, Path.ChangeExtension(ProxyLauncherFileName, ".runtimeconfig.json")); + if (File.Exists(proxyConfig)) + { + File.Delete(proxyConfig); + } + } + } + catch (IOException ex) + { + logger.LogWarning(ex, "Failed to cleanup proxy launcher during undo"); + return Task.FromResult(new ActionSetResult(false, $"Failed to cleanup proxy launcher: {ex.Message}")); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access denied during proxy launcher undo"); + return Task.FromResult(new ActionSetResult(false, $"Access denied during proxy launcher cleanup: {ex.Message}")); + } + + return Task.FromResult(new ActionSetResult(true, null, [$"Cleaned up proxy launcher assets (restored {restoredCount} items)."])); + } + + private static string ResolveProxySourcePath() + { + var currentBaseDir = AppDomain.CurrentDomain.BaseDirectory; + var defaultPath = Path.Combine(currentBaseDir, ProxyLauncherFileName); + if (File.Exists(defaultPath)) + { + return defaultPath; + } + + var developmentPaths = new[] + { + Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Release", "net8.0-windows", "win-x64", ProxyLauncherFileName)), + Path.GetFullPath(Path.Combine(currentBaseDir, "..", "..", "..", "..", "GenHub.ProxyLauncher", "bin", "Debug", "net8.0-windows", "win-x64", ProxyLauncherFileName)), + Path.GetFullPath(Path.Combine(currentBaseDir, "net8.0-windows", ProxyLauncherFileName)), + }; + + return developmentPaths.FirstOrDefault(File.Exists) ?? defaultPath; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/RemoveReadOnlyFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/RemoveReadOnlyFix.cs new file mode 100644 index 000000000..8cb50b9d3 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/RemoveReadOnlyFix.cs @@ -0,0 +1,309 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that removes Read-Only attribute from game files and user data folders, +/// and applies the 'Pinned' attribute for OneDrive compatibility. +/// +public class RemoveReadOnlyFix(ILogger logger) : BaseActionSet(logger) +{ + // Marker file to definitively track if GenPatcher applied this fix + private const string MarkerFileName = ActionSetConstants.Paths.ReadOnlyFixMarker; + + private static string GetUserDataPath(GameType gameType) + { + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var folder = gameType == GameType.ZeroHour + ? GameSettingsConstants.FolderNames.ZeroHour + : GameSettingsConstants.FolderNames.Generals; + return Path.Combine(documents, folder); + } + + private static async Task<(int Files, int Dirs)> RemoveReadOnlyRecursiveAsync(DirectoryInfo directory, ILogger logger, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + int filesProcessed = 0; + int dirsProcessed = 0; + + try + { + if ((directory.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + { + directory.Attributes &= ~FileAttributes.ReadOnly; + dirsProcessed++; + } + + foreach (var file in directory.GetFiles()) + { + if ((file.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) + { + file.Attributes &= ~FileAttributes.ReadOnly; + filesProcessed++; + } + } + + foreach (var subDir in directory.GetDirectories()) + { + var (f, d) = await RemoveReadOnlyRecursiveAsync(subDir, logger, ct); + filesProcessed += f; + dirsProcessed += d; + } + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Access denied to {Path}", directory.FullName); + } + + return (filesProcessed, dirsProcessed); + } + + /// + public override string Id => "RemoveReadOnlyFix"; + + /// + public override string Title => "Remove Read-Only Attributes"; + + /// + public override string Description => "Recursively removes Read-Only file locks from game and document folders so settings, maps, and replays can be saved."; + + /// + public override string DetailedDescription => "Older CD installations and archive extractions frequently lock game directories as Read-Only, preventing Generals from saving configuration changes, downloading custom maps, or recording replays. This fix clears all read-only attributes across your installation and user data directories."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + if (installation.HasGenerals && !IsGameApplied(GameType.Generals, installation.GeneralsPath)) + { + return Task.FromResult(false); + } + + if (installation.HasZeroHour && !IsGameApplied(GameType.ZeroHour, installation.ZeroHourPath)) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting read-only attribute removal..."); + + int totalFilesProcessed = 0; + int totalDirsProcessed = 0; + + if (installation.HasGenerals) + { + details.Add($"Processing Generals installation: {installation.GeneralsPath}"); + var (files, dirs) = await ProcessDirectoryAsync(installation.GeneralsPath, details, ct); + totalFilesProcessed += files; + totalDirsProcessed += dirs; + + var userPath = GetUserDataPath(GameType.Generals); + if (Directory.Exists(userPath)) + { + details.Add($"Processing Generals user data: {userPath}"); + (int uFiles, int uDirs) = await ProcessDirectoryAsync(userPath, details, ct); + totalFilesProcessed += uFiles; + totalDirsProcessed += uDirs; + + // Write marker file for Generals + try + { + var markerPath = Path.Combine(userPath, MarkerFileName); + await File.WriteAllTextAsync(markerPath, DateTime.UtcNow.ToString("O"), ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create Generals marker file for RemoveReadOnlyFix"); + } + } + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour installation: {installation.ZeroHourPath}"); + var (files, dirs) = await ProcessDirectoryAsync(installation.ZeroHourPath, details, ct); + totalFilesProcessed += files; + totalDirsProcessed += dirs; + + var userPath = GetUserDataPath(GameType.ZeroHour); + if (Directory.Exists(userPath)) + { + details.Add($"Processing Zero Hour user data: {userPath}"); + (int uFiles, int uDirs) = await ProcessDirectoryAsync(userPath, details, ct); + totalFilesProcessed += uFiles; + totalDirsProcessed += uDirs; + + // Write marker file for Zero Hour + try + { + var markerPath = Path.Combine(userPath, MarkerFileName); + await File.WriteAllTextAsync(markerPath, DateTime.UtcNow.ToString("O"), ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create Zero Hour marker file for RemoveReadOnlyFix"); + } + } + } + + details.Add($"✓ Processed {totalFilesProcessed} files and {totalDirsProcessed} directories"); + details.Add("✓ Read-only attributes removed successfully"); + details.Add("✓ OneDrive pin attributes applied"); + + logger.LogInformation("RemoveReadOnlyFix completed: {Files} files, {Dirs} directories", totalFilesProcessed, totalDirsProcessed); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove read-only attributes"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogInformation("Re-applying read-only attributes is not supported to ensure game and patch accessibility."); + return Task.FromResult(new ActionSetResult(false, "Re-applying read-only attributes is not supported as write access is required for game saves, settings, and mod updates.", ["Read-only attributes remain cleared."])); + } + + private bool IsReadOnly(string path) + { + if (!File.Exists(path) && !Directory.Exists(path)) return false; + + try + { + var attributes = File.GetAttributes(path); + return (attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly; + } + catch (Exception ex) + { + logger.LogError(ex, "Could not check attributes for {Path}", path); + return false; + } + } + + private async Task<(int Files, int Dirs)> ProcessDirectoryAsync(string path, List details, CancellationToken ct) + { + if (!Directory.Exists(path)) return (0, 0); + + logger.LogInformation("Removing read-only and pinning files in: {Path}", path); + + int filesProcessed = 0; + int dirsProcessed = 0; + + // 1. Remove Read-Only attribute recursively using built-in File API + try + { + var dirInfo = new DirectoryInfo(path); + var (f, d) = await RemoveReadOnlyRecursiveAsync(dirInfo, logger, ct); + filesProcessed += f; + dirsProcessed += d; + + details.Add($" ✓ Removed read-only from {f} files, {d} directories"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error removing read-only attributes for {Path}", path); + details.Add($" ⚠ Warning: {ex.Message}"); + } + + // 2. Apply Pin attribute (+P -U) using PowerShell for OneDrive compatibility + // This is what GenPatcher's ApplyPinAttributeToFile does. + try + { + await ApplyPinAttributeAsync(path, ct); + details.Add(" ✓ Applied OneDrive pin attributes"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to apply pin attributes to {Path}", path); + details.Add($" ⚠ Could not apply pin attributes: {ex.Message}"); + } + + return (filesProcessed, dirsProcessed); + } + + private async Task ApplyPinAttributeAsync(string path, CancellationToken ct) + { + try + { + // Use PowerShell to apply 'Pinned' attribute which is specific to modern Windows / OneDrive + // Attrib +P -U + var psi = new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"), + Arguments = $"-WindowStyle Hidden -NoProfile -NonInteractive -Command \"Get-ChildItem -Path '{path.Replace("'", "''")}' -Recurse | ForEach-Object {{ attrib +P -U $_.FullName }}\"", + CreateNoWindow = true, + UseShellExecute = false, + }; + + using var process = Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(ct); + if (process.ExitCode != ProcessConstants.ExitCodeSuccess) + { + logger.LogWarning("attrib command exited with code {Code} for {Path}", process.ExitCode, path); + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to apply pin attributes to {Path}", path); + } + } + + private bool IsGameApplied(GameType gameType, string gamePath) + { + if (IsReadOnly(gamePath)) + { + return false; + } + + var userPath = GetUserDataPath(gameType); + if (!Directory.Exists(userPath)) + { + return true; + } + + var markerPath = Path.Combine(userPath, MarkerFileName); + if (!File.Exists(markerPath) || IsReadOnly(userPath)) + { + return false; + } + + string[] keyPaths = ["Options.ini", "Maps", "Replays"]; + return keyPaths.All(p => !IsReadOnly(Path.Combine(userPath, p))); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/SerialKeyFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/SerialKeyFix.cs new file mode 100644 index 000000000..1077d1dd5 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/SerialKeyFix.cs @@ -0,0 +1,167 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// Fix that detects and replaces placeholder serial keys (ergc) in the registry. +/// This prevents "Serial key already in use" errors and enables C&C Online play. +/// +public class SerialKeyFix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + private const string PlaceholderSerial1 = "12345678901234567890"; + private const string PlaceholderSerialZero = "00000000000000000000"; + private const string PlaceholderSerialDashes = "0000-0000-0000-0000-0000"; + + /// + public override string Id => "SerialKeyFix"; + + /// + public override string Title => "Fix Serial Keys"; + + /// + public override string Description => "Replaces shared placeholder CD keys in the registry with unique keys to eliminate \"Serial key already in use\" errors."; + + /// + public override string DetailedDescription => "Digital releases from Steam and the EA App install identical placeholder serial keys for all users, making online multiplayer impossible due to serial key conflicts. This fix generates and registers a unique, valid CD key in your Windows registry so you can play on C&C:Online and LAN without conflicts."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + if (installation.HasGenerals) + { + var serial = registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) return Task.FromResult(true); + } + + if (installation.HasZeroHour) + { + var serial = registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + if (installation.HasGenerals) + { + var serial = registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) return Task.FromResult(false); + } + + if (installation.HasZeroHour) + { + var serial = registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) return Task.FromResult(false); + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking serial key status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Checking game serial keys..."); + bool generalsSuccess = !installation.HasGenerals || ApplyGameSerial("Generals", RegistryConstants.EAAppGeneralsErgcKeyPath, details); + bool zhSuccess = !installation.HasZeroHour || ApplyGameSerial("Zero Hour", RegistryConstants.EAAppZeroHourErgcKeyPath, details); + + if (!generalsSuccess || !zhSuccess) + { + return Task.FromResult(new ActionSetResult(false, "Failed to apply one or more serial keys.", details)); + } + + details.Add("✓ Serial key fix completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying serial key fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + logger.LogInformation("Undoing serial key generation is not supported as removing keys will prevent the game from starting."); + return Task.FromResult(new ActionSetResult(false, "Undoing serial key configuration is not supported as valid serial keys are required for game execution.", ["Valid serial keys remain in registry."])); + } + + private static bool IsPlaceholder(string? serial) + { + if (string.IsNullOrEmpty(serial)) return true; + + var s = serial.Trim(); + return s == PlaceholderSerial1 || + s == PlaceholderSerialZero || + s == PlaceholderSerialDashes || + s == ActionSetConstants.Serials.DefaultEAAppGeneralsSerial || + s == ActionSetConstants.Serials.DefaultEAAppZeroHourSerial; + } + + private static string GenerateRandomSerial() + { + var sb = new System.Text.StringBuilder("GP2", 20); + for (int i = 0; i < 17; i++) + { + sb.Append(System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10)); + } + + return sb.ToString(); + } + + private bool ApplyGameSerial(string gameName, string ergcKeyPath, List details) + { + var serial = registryService.GetStringValue(ergcKeyPath, string.Empty); + if (!IsPlaceholder(serial)) + { + details.Add($" ✓ {gameName} serial is already valid"); + return true; + } + + var newSerial = GenerateRandomSerial(); + details.Add($" Found placeholder serial for {gameName}. Generating new one..."); + if (registryService.SetStringValue(ergcKeyPath, string.Empty, newSerial)) + { + details.Add($" ✓ Applied new serial to {ergcKeyPath}"); + return true; + } + + details.Add($" ✗ Failed to apply new serial for {gameName} (permissions?)"); + return false; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/StartMenuFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/StartMenuFix.cs new file mode 100644 index 000000000..19f7e3458 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/StartMenuFix.cs @@ -0,0 +1,273 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.Shortcuts; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that creates or fixes start menu shortcuts for Generals and Zero Hour. +/// This fix ensures proper shortcuts are available in Windows Start Menu. +/// +public class StartMenuFix(IShortcutService shortcutService, ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "StartMenuFix"; + + /// + public override string Title => "Start Menu Shortcuts"; + + /// + public override string Description => "Creates Windows Start Menu shortcuts for Generals, Zero Hour, and Windowed Mode gameplay."; + + /// + public override string DetailedDescription => "Digital installations often fail to create clean Start Menu shortcuts or windowed mode launch targets. This fix generates official Windows Start Menu shortcuts, including dedicated windowed mode launchers and EdgeScroller entries for seamless multi-monitor gaming."; + + /// + public override string Category => ActionSetConstants.Categories.QualityOfLife; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + return Task.FromResult(DoShortcutsExist(installation)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking start menu shortcuts status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Creating Start Menu shortcuts..."); + var commonPrograms = Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms); + + var (genCreated, genFailed) = await CreateGeneralsShortcutsAsync(installation, commonPrograms, details); + var (zhCreated, zhFailed) = await CreateZeroHourShortcutsAsync(installation, commonPrograms, details); + + var totalCreated = genCreated + zhCreated; + var hasFailures = genFailed || zhFailed; + + if (hasFailures) + { + return new ActionSetResult(false, "Failed to create one or more Start Menu shortcuts", details); + } + + if (totalCreated == 0) + { + details.Add("⚠ No game executables found to create shortcuts for."); + return new ActionSetResult(false, "No game executables found to create shortcuts.", details); + } + + details.Add(string.Empty); + details.Add($"✓ Start Menu shortcuts created successfully ({totalCreated} shortcuts)"); + + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying start menu shortcuts fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + var commonPrograms = Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms); + + try + { + if (installation.HasGenerals) + { + var folder = Path.Combine(commonPrograms, "Command and Conquer Generals"); + var lnk = Path.Combine(folder, "Command & Conquer Generals Windowed.lnk"); + if (File.Exists(lnk)) + { + File.Delete(lnk); + details.Add("✓ Removed Generals windowed shortcut"); + } + + if (Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) + { + Directory.Delete(folder); + } + } + + if (installation.HasZeroHour) + { + var folder = Path.Combine(commonPrograms, "Command and Conquer Generals Zero Hour"); + var lnk1 = Path.Combine(folder, "Command & Conquer Generals Zero Hour Windowed.lnk"); + var lnk2 = Path.Combine(folder, "EdgeScroller.lnk"); + if (File.Exists(lnk1)) + { + File.Delete(lnk1); + details.Add("✓ Removed Zero Hour windowed shortcut"); + } + + if (File.Exists(lnk2)) + { + File.Delete(lnk2); + details.Add("✓ Removed EdgeScroller shortcut"); + } + + if (Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) + { + Directory.Delete(folder); + } + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing Start Menu shortcuts fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private static bool DoShortcutsExist(GameInstallation installation) + { + var searchPaths = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), + Environment.GetFolderPath(Environment.SpecialFolder.Programs), + }; + + bool generalsFound = !installation.HasGenerals || HasAnyShortcut( + searchPaths, + ["Command and Conquer Generals", "Command & Conquer Generals"], + "Command & Conquer Generals Windowed.lnk"); + + bool zhFound = !installation.HasZeroHour || HasAnyShortcut( + searchPaths, + ["Command and Conquer Generals Zero Hour", "Command & Conquer Generals Zero Hour"], + "Command & Conquer Generals Zero Hour Windowed.lnk"); + + return generalsFound && zhFound; + } + + private static bool HasAnyShortcut(string[] searchPaths, string[] folderVariants, string shortcutFileName) + { + return searchPaths.Any(programsPath => + folderVariants.Any(folder => + File.Exists(Path.Combine(programsPath, folder, shortcutFileName)))); + } + + private async Task<(int Created, bool HasFailures)> CreateGeneralsShortcutsAsync( + GameInstallation installation, + string commonPrograms, + List details) + { + if (!installation.HasGenerals) + { + return (0, false); + } + + var startMenuPath = Path.Combine(commonPrograms, "Command and Conquer Generals"); + var exe = Path.Combine(installation.GeneralsPath, "Generals.exe"); + var shortcutPath = Path.Combine(startMenuPath, "Command & Conquer Generals Windowed.lnk"); + + var (created, failed) = await CreateShortcutIfExeExistsAsync( + shortcutPath, + exe, + "-win", + installation.GeneralsPath, + "Launch Generals in Windowed Mode", + details); + + return (created ? 1 : 0, failed); + } + + private async Task<(int Created, bool HasFailures)> CreateZeroHourShortcutsAsync( + GameInstallation installation, + string commonPrograms, + List details) + { + if (!installation.HasZeroHour) + { + return (0, false); + } + + int createdCount = 0; + bool hasFailures = false; + + var startMenuPath = Path.Combine(commonPrograms, "Command and Conquer Generals Zero Hour"); + var exe = Path.Combine(installation.ZeroHourPath, "generals.exe"); + var shortcutPath = Path.Combine(startMenuPath, "Command & Conquer Generals Zero Hour Windowed.lnk"); + + var (created, failed) = await CreateShortcutIfExeExistsAsync( + shortcutPath, + exe, + "-win", + installation.ZeroHourPath, + "Launch Zero Hour in Windowed Mode", + details); + + if (created) createdCount++; + if (failed) hasFailures = true; + + var edgeScroller = Path.Combine(installation.ZeroHourPath, "EdgeScroller.exe"); + var edgeScrollerShortcut = Path.Combine(startMenuPath, "EdgeScroller.lnk"); + + var (esCreated, esFailed) = await CreateShortcutIfExeExistsAsync( + edgeScrollerShortcut, + edgeScroller, + null, + installation.ZeroHourPath, + "Window Edge Scroller", + details); + + if (esCreated) createdCount++; + if (esFailed) hasFailures = true; + + return (createdCount, hasFailures); + } + + private async Task<(bool Created, bool Failed)> CreateShortcutIfExeExistsAsync( + string shortcutPath, + string exePath, + string? arguments, + string workingDir, + string description, + List details) + { + if (!File.Exists(exePath)) + { + return (false, false); + } + + var result = await shortcutService.CreateShortcutAsync(shortcutPath, exePath, arguments, workingDir, description); + if (result.Success) + { + details.Add($"✓ Created: {Path.GetFileName(shortcutPath)}"); + return (true, false); + } + + details.Add($"✗ Failed to create {Path.GetFileName(shortcutPath)}: {result.Errors.FirstOrDefault()}"); + return (false, true); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/TheFirstDecadeRegistryFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/TheFirstDecadeRegistryFix.cs new file mode 100644 index 000000000..77dc48822 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/TheFirstDecadeRegistryFix.cs @@ -0,0 +1,174 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// Fix that creates registry entries for The First Decade (TFD) version detection. +/// This ensures the game can properly detect if it's running from TFD installation. +/// +public class TheFirstDecadeRegistryFix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + /// + public override string Id => "TheFirstDecadeRegistryFix"; + + /// + public override string Title => "The First Decade Registry"; + + /// + public override string Description => "Restores missing \"The First Decade\" registry keys required for proper game detection and patch installation."; + + /// + public override string DetailedDescription => "Command & Conquer: The First Decade compilation installs rely on central registry keys to link Generals and Zero Hour to official patches and tools. This fix locates your TFD base folder and rebuilds the required registry entries so expansions recognize your installation."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + // Check if TFD registry entries exist + var tfdInstalled = registryService.GetStringValue( + RegistryConstants.TheFirstDecadeKeyPath, + RegistryConstants.InstallPathValueName); + + return Task.FromResult(!string.IsNullOrEmpty(tfdInstalled)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking TFD registry status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting The First Decade registry configuration..."); + + // Determine the base installation path + string basePath = installation.HasGenerals + ? installation.GeneralsPath + : installation.ZeroHourPath; + + details.Add($"Detecting TFD installation path from: {basePath}"); + + // Navigate up to find the TFD base directory + var tfdPath = FindTFDPath(basePath); + if (string.IsNullOrEmpty(tfdPath)) + { + details.Add("✗ Could not determine TFD installation path"); + details.Add(" Game may not be installed as part of The First Decade"); + logger.LogWarning("Could not determine TFD installation path"); + return Task.FromResult(new ActionSetResult(false, "Could not determine TFD installation path", details)); + } + + details.Add($"✓ Detected TFD path: {tfdPath}"); + details.Add("Creating TFD registry entries..."); + + // Create TFD registry entries + var s1 = registryService.SetStringValue( + RegistryConstants.TheFirstDecadeKeyPath, + RegistryConstants.InstallPathValueName, + tfdPath); + + var s2 = registryService.SetStringValue( + RegistryConstants.TheFirstDecadeKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.TfdVersionData); + + if (!s1 || !s2) + { + details.Add("✗ Failed to write The First Decade registry entries (permissions?)"); + return Task.FromResult(new ActionSetResult(false, "Failed to write The First Decade registry entries", details)); + } + + details.Add($"✓ Created: HKLM\\{RegistryConstants.TheFirstDecadeKeyPath}"); + details.Add($" • InstallPath = {tfdPath}"); + details.Add($" • Version = {RegistryConstants.TfdVersionData}"); + details.Add("✓ The First Decade registry configuration completed successfully"); + + logger.LogInformation("Successfully created TFD registry entries at {Path} with {Count} actions", tfdPath, details.Count); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying TFD registry fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Removing The First Decade registry entries..."); + registryService.DeleteValue(RegistryConstants.TheFirstDecadeKeyPath, RegistryConstants.InstallPathValueName); + registryService.DeleteValue(RegistryConstants.TheFirstDecadeKeyPath, RegistryConstants.VersionValueName); + details.Add($"✓ Removed registry entries for HKLM\\{RegistryConstants.TheFirstDecadeKeyPath}"); + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error undoing The First Decade registry fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private string? FindTFDPath(string gamePath) + { + try + { + var directory = new DirectoryInfo(gamePath); + + // Direct parent is TFD (e.g. C:\TFD\Command & Conquer Generals Zero Hour) + if (directory.Parent?.Name.Contains("The First Decade", StringComparison.OrdinalIgnoreCase) == true || + directory.Parent?.Name.Contains("First Decade", StringComparison.OrdinalIgnoreCase) == true) + { + return directory.Parent.FullName; + } + + // Grandparent is TFD (e.g. C:\TFD\Command & Conquer Generals\...) + if (directory.Parent?.Parent?.Name.Contains("The First Decade", StringComparison.OrdinalIgnoreCase) == true || + directory.Parent?.Parent?.Name.Contains("First Decade", StringComparison.OrdinalIgnoreCase) == true) + { + return directory.Parent.Parent.FullName; + } + + return directory.Parent?.FullName ?? gamePath; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error finding TFD path"); + return null; + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2005Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2005Fix.cs new file mode 100644 index 000000000..0984b2df7 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2005Fix.cs @@ -0,0 +1,108 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Fix that checks for and installs Visual C++ 2005 Redistributable (x86). +/// Required for some legacy components and GenPatcher parity. +/// +public class VCRedist2005Fix(IHttpClientFactory httpClientFactory, ILogger logger) + : BaseVCRedistFix(httpClientFactory, logger) +{ + private const string Vc2005ProductCode = "{7299052b-02a4-4627-81f2-1818da5d550d}"; + + /// + public override string Id => "VCRedist2005Fix"; + + /// + public override string Title => "Visual C++ 2005 Runtime"; + + /// + public override string Description => "Installs the Microsoft Visual C++ 2005 x86 system runtime package (also managed in Downloads)."; + + /// + public override string DetailedDescription => "Several legacy game tools and community plugins require the 32-bit Visual C++ 2005 runtime libraries (msvcr80.dll). This package downloads and installs the official Microsoft runtime to prevent missing DLL startup errors. You can also download and manage this package from the Downloads section."; + + /// + public override bool IsCrucialFix => false; + + /// + protected override IReadOnlyList DownloadUrls => + [ + ExternalUrls.VCRedist2005DownloadUrlPrimary, + ExternalUrls.VCRedist2005DownloadUrlMirror1, + ]; + + /// + protected override string InstallerArguments => "/q"; + + /// + protected override string RedistDisplayName => "Visual C++ 2005 Redistributable"; + + /// + protected override string TempFilePrefix => "vcredist_2005_x86"; + + /// + protected override long MinimumFileSizeBytes => 1024 * 1024; // ~2.6 MB + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + if (IsProductInstalled(Vc2005ProductCode)) + { + return Task.FromResult(true); + } + + try + { + using var key1 = Registry.LocalMachine.OpenSubKey(RegistryConstants.VCRedist2005InstallerProductsKey); + if (key1 != null) + { + return Task.FromResult(true); + } + + using var key2 = Registry.LocalMachine.OpenSubKey(RegistryConstants.VCRedist2005InstallerProductsKeyWow64); + if (key2 != null) + { + return Task.FromResult(true); + } + + using var key3 = Registry.LocalMachine.OpenSubKey(RegistryConstants.VCRedist2005ClassesKey); + if (key3 != null) + { + return Task.FromResult(true); + } + } + catch (System.Security.SecurityException ex) + { + logger.LogDebug(ex, "Security exception inspecting VC++ 2005 redistributable registry subkey"); + } + catch (UnauthorizedAccessException ex) + { + logger.LogDebug(ex, "Unauthorized access inspecting VC++ 2005 redistributable registry subkey"); + } + catch (IOException ex) + { + logger.LogDebug(ex, "I/O error inspecting VC++ 2005 redistributable registry subkey"); + } + catch (ArgumentException ex) + { + logger.LogDebug(ex, "Argument exception inspecting VC++ 2005 redistributable registry subkey"); + } + catch (ObjectDisposedException ex) + { + logger.LogDebug(ex, "Registry key disposed inspecting VC++ 2005 redistributable registry subkey"); + } + + return Task.FromResult(false); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2008Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2008Fix.cs new file mode 100644 index 000000000..5337452b4 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2008Fix.cs @@ -0,0 +1,80 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Fix that checks for and installs Visual C++ 2008 Redistributable (x86). +/// Required for some legacy components and GenPatcher parity. +/// +public class VCRedist2008Fix(IHttpClientFactory httpClientFactory, ILogger logger) + : BaseVCRedistFix(httpClientFactory, logger) +{ + private const string Vc2008ProductCode = "{9A25302D-30C0-39D9-BD6F-21E6EC160475}"; + + /// + public override string Id => "VCRedist2008Fix"; + + /// + public override string Title => "Visual C++ 2008 Runtime"; + + /// + public override string Description => "Installs the Microsoft Visual C++ 2008 x86 system runtime package (also managed in Downloads)."; + + /// + public override string DetailedDescription => "Community tools, map editors, and mod patchers require the 32-bit Visual C++ 2008 runtime libraries (msvcr90.dll). This package downloads and installs the official Microsoft runtime to ensure community utilities start properly. You can also download and manage this package from the Downloads section."; + + /// + public override bool IsCrucialFix => false; + + /// + protected override IReadOnlyList DownloadUrls => + [ + ExternalUrls.VCRedist2008DownloadUrlPrimary, + ExternalUrls.VCRedist2008DownloadUrlMirror1, + ]; + + /// + protected override string InstallerArguments => "/q"; + + /// + protected override string RedistDisplayName => "Visual C++ 2008 Redistributable"; + + /// + protected override string TempFilePrefix => "vcredist_2008_x86"; + + /// + protected override long MinimumFileSizeBytes => 1024 * 1024; // ~4.3 MB + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + if (IsProductInstalled(Vc2008ProductCode)) + { + return Task.FromResult(true); + } + + try + { + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Classes\Installer\Products\D20352A90C039D93DBF6126ECE614057"); + return Task.FromResult(key != null); + } + catch (System.Security.SecurityException ex) + { + logger.LogDebug(ex, "Security exception checking VC++ 2008 registry key"); + return Task.FromResult(false); + } + catch (UnauthorizedAccessException ex) + { + logger.LogDebug(ex, "Unauthorized access checking VC++ 2008 registry key"); + return Task.FromResult(false); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2010Fix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2010Fix.cs new file mode 100644 index 000000000..c5a5523b7 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2010Fix.cs @@ -0,0 +1,82 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Installs the Visual C++ 2010 Redistributable (x86) which is required for Generals/Zero Hour. +/// +public class VCRedist2010Fix(IHttpClientFactory httpClientFactory, ILogger logger) + : BaseVCRedistFix(httpClientFactory, logger) +{ + /// + public override string Id => "VCRedist2010"; + + /// + public override string Title => "Visual C++ 2010 Runtime"; + + /// + public override string Description => "Installs the Microsoft Visual C++ 2010 x86 system runtime package (also managed in Downloads)."; + + /// + public override string DetailedDescription => "GenTool, widescreen hooks, and community tools depend on the 32-bit Visual C++ 2010 runtime libraries (msvcr100.dll). This package downloads and installs the official Microsoft runtime to prevent missing DLL errors. You can also download and manage this package from the Downloads section."; + + /// + public override bool IsCrucialFix => false; + + /// + protected override IReadOnlyList DownloadUrls => [ExternalUrls.VCRedist2010DownloadUrl]; + + /// + protected override string InstallerArguments => "/quiet /norestart"; + + /// + protected override string RedistDisplayName => "Visual C++ 2010 Redistributable"; + + /// + protected override string TempFilePrefix => "vcredist_x86_2010"; + + /// + protected override long MinimumFileSizeBytes => 1024 * 1024; // ~4.8 MB + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + try + { + using var key = Registry.LocalMachine.OpenSubKey(RegistryConstants.VCRedist2010x86Key); + if (key != null) + { + var val = key.GetValue(RegistryConstants.InstalledValueName); + if (val != null && (int)val == 1) + { + return Task.FromResult(true); + } + } + + using var key64 = Registry.LocalMachine.OpenSubKey(RegistryConstants.VCRedist2010x86KeyWow64); + if (key64 != null) + { + var val = key64.GetValue(RegistryConstants.InstalledValueName); + if (val != null && (int)val == 1) + { + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to check VCRedist 2010 registry status"); + return Task.FromResult(false); + } + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VanillaExecutableFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VanillaExecutableFix.cs new file mode 100644 index 000000000..79c61441a --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VanillaExecutableFix.cs @@ -0,0 +1,52 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System.Collections.Generic; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that ensures that Generals executable is properly patched. +/// This fix checks if the official 1.08 patch has been applied. +/// +public class VanillaExecutableFix(ILogger logger) : BaseExecutableVersionFix(logger) +{ + /// + public override string Id => "VanillaExecutableFix"; + + /// + public override string Title => "Generals 1.08 Version Check"; + + /// + public override string Description => "Verifies that the Generals game client executable is updated to official version 1.08."; + + /// + public override string DetailedDescription => "Running an unpatched version of Generals causes multiplayer version mismatches and crashes. This diagnostic verifies that your base game executable is present and updated to official version 1.08. If outdated, use the Downloads section or Patch 1.08 to update your game client."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + protected override string GameDisplayName => "Generals"; + + /// + protected override string TargetVersionDisplay => "1.08"; + + /// + protected override IReadOnlyList VersionPrefixes => ["1.8", "1.08"]; + + /// + protected override IReadOnlyList CandidateExecutableNames => [ActionSetConstants.FileNames.GeneralsExe]; + + /// + protected override bool HasGame(GameInstallation installation) => installation.HasGenerals; + + /// + protected override string? GetGamePath(GameInstallation installation) => installation.GeneralsPath; +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/WindowsMediaFeaturePack.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/WindowsMediaFeaturePack.cs new file mode 100644 index 000000000..182deed76 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/WindowsMediaFeaturePack.cs @@ -0,0 +1,167 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that checks for Windows Media Feature Pack installation. +/// The Media Feature Pack is required for some media playback features in Windows N editions. +/// +public class WindowsMediaFeaturePack(ILogger logger) : BaseActionSet(logger) +{ + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", ActionSetConstants.Paths.SubActionSetMarkers, "WindowsMediaFeaturePack.done"); + + /// + public override string Id => "WindowsMediaFeaturePack"; + + /// + public override string Title => "Windows Media Feature Pack"; + + /// + public override string Description => "Checks for Windows Media Feature Pack on Windows N editions to prevent video cutscene and audio crashes."; + + /// + public override string DetailedDescription => "Windows N and KN editions lack essential media codecs required to play Generals and Zero Hour intro movies, campaign briefings, and background audio. This fix detects missing media components and guides you through enabling the Windows Media Feature Pack."; + + /// + public override string Category => ActionSetConstants.Categories.Compatibility; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // Only applicable if Media Feature Pack is NOT installed (needs fixing) + var mediaPackInstalled = IsMediaFeaturePackInstalled(); + return Task.FromResult(!mediaPackInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) + { + if (MarkerExists(_markerPath)) return Task.FromResult(true); + return Task.FromResult(IsMediaFeaturePackInstalled()); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + try + { + var mediaPackInstalled = IsMediaFeaturePackInstalled(); + + if (mediaPackInstalled) + { + logger.LogInformation("Windows Media Feature Pack is already installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + var osVersion = Environment.OSVersion.Version; + var isWindows10OrLater = osVersion >= new Version(10, 0); + + if (!isWindows10OrLater) + { + logger.LogInformation("Windows Media Feature Pack is only available for Windows 10 and later. Your Windows version: {Version}", osVersion); + return Task.FromResult(new ActionSetResult(true, null, ["Media Feature Pack not available for your Windows version."])); + } + + logger.LogWarning("Windows Media Feature Pack is not installed. Please install it from Windows Settings > Optional features > Add a feature, or visit {Url}", ExternalUrls.WindowsMediaFeaturePackSupportUrl); + + WriteMarkerFile(_markerPath); + + return Task.FromResult(new ActionSetResult(true, null, ["Please manually install Windows Media Feature Pack. See logs for details."])); + } + catch (Exception ex) + { + logger.LogError(ex, "Error applying Media Feature Pack fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + DeleteMarkerFile(_markerPath); + return Task.FromResult(new ActionSetResult(true, null, ["Media Feature Pack marker removed."])); + } + + private static bool IsPackageInstalled(Microsoft.Win32.RegistryKey subKey) + { + var installStateVal = subKey.GetValue(RegistryConstants.InstallStateValueName); + if (installStateVal is int stateInt && + (stateInt == RegistryConstants.CbsInstallStateStaged || + stateInt == RegistryConstants.CbsInstallStateInstalled || + stateInt == RegistryConstants.CbsInstallStateSuperseded)) + { + return true; + } + + return installStateVal is string installState && installState.Equals("Installed", StringComparison.OrdinalIgnoreCase); + } + + private bool IsMediaFeaturePackInstalled() + { + try + { + return HasMediaFeaturePackInRegistry() || HasWindowsMediaPlayer(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking for Media Feature Pack"); + return false; + } + } + + private bool HasMediaFeaturePackInRegistry() + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(RegistryConstants.CbsPackagesKeyPath, false); + if (key == null) + { + return false; + } + + foreach (var subKeyName in key.GetSubKeyNames()) + { + if (!subKeyName.Contains("MediaFeaturePack", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey != null && IsPackageInstalled(subKey)) + { + logger.LogInformation("Found Media Feature Pack: {Package}", subKeyName); + return true; + } + } + + return false; + } + + private bool HasWindowsMediaPlayer() + { + var wmpPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Windows Media Player", + "wmplayer.exe"); + + if (File.Exists(wmpPath)) + { + logger.LogInformation("Found Windows Media Player: {Path}", wmpPath); + return true; + } + + return false; + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ZeroHourExecutableFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ZeroHourExecutableFix.cs new file mode 100644 index 000000000..8cbdef6fb --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ZeroHourExecutableFix.cs @@ -0,0 +1,67 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that ensures that Zero Hour executable is properly patched. +/// This fix checks if that official 1.04 patch has been applied. +/// +public class ZeroHourExecutableFix(ILogger logger) : BaseExecutableVersionFix(logger) +{ + private static readonly IReadOnlyList CandidateExes = + [ + ActionSetConstants.FileNames.GeneralsExe, + ActionSetConstants.FileNames.GameExe, + ]; + + /// + public override string Id => "ZeroHourExecutableFix"; + + /// + public override string Title => "Zero Hour 1.04 Version Check"; + + /// + public override string Description => "Verifies that the Zero Hour game client executable is updated to official version 1.04."; + + /// + public override string DetailedDescription => "Zero Hour requires official executable version 1.04 to support multiplayer, GenTool, and modern community mods. This diagnostic validates your game executables. If outdated, use the Downloads section or Patch 1.04 to update your game client."; + + /// + public override string Category => ActionSetConstants.Categories.CoreAndStability; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + protected override string GameDisplayName => "Zero Hour"; + + /// + protected override string TargetVersionDisplay => "1.04"; + + /// + protected override IReadOnlyList VersionPrefixes => ["1.4", "1.04"]; + + /// + protected override IReadOnlyList CandidateExecutableNames => CandidateExes; + + /// + public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) + { + // User requested to disable this fix as it is handled by the Downloads tab + return Task.FromResult(false); + } + + /// + protected override bool HasGame(GameInstallation installation) => installation.HasZeroHour; + + /// + protected override string? GetGamePath(GameInstallation installation) => installation.ZeroHourPath; +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs b/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs new file mode 100644 index 000000000..6bcceafa8 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs @@ -0,0 +1,62 @@ +namespace GenHub.Windows.Features.ActionSets; + +using System; +using Avalonia.Controls; +using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Models.Tools; +using GenHub.Windows.Features.ActionSets.UI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +/// +/// Tool plugin for GenPatcher functionality. +/// +/// The logger instance. +public class GenPatcherTool(ILogger logger) : IToolPlugin +{ + private IServiceProvider? _serviceProvider; + + /// + public ToolMetadata Metadata => new() + { + Id = "GenPatcher", + Name = "GenPatcher", + Author = "Legionnaire (Ported)", + Version = "1.0.0", + Description = "Apply essential fixes and patches to Command & Conquer Generals and Zero Hour.", + Tags = ["Fixes", "Patching", "System"], + }; + + /// + public Control CreateControl() + { + var view = new GenPatcherToolView(); + + // If we have the service provider, resolve the VM + if (_serviceProvider != null) + { + view.DataContext = _serviceProvider.GetRequiredService(); + } + + return view; + } + + /// + public void OnActivated(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + logger.LogInformation("GenPatcher Tool Activated"); + } + + /// + public void OnDeactivated() + { + logger.LogInformation("GenPatcher Tool Deactivated"); + } + + /// + public void Dispose() + { + // Cleanup if needed + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Infrastructure/IRegistryService.cs b/GenHub/GenHub.Windows/Features/ActionSets/Infrastructure/IRegistryService.cs new file mode 100644 index 000000000..e40dfc20d --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Infrastructure/IRegistryService.cs @@ -0,0 +1,250 @@ +namespace GenHub.Windows.Features.ActionSets.Infrastructure; + +using System; +using System.Security.Principal; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +/// +/// Service for interacting with the Windows Registry. +/// +public interface IRegistryService +{ + /// + /// Gets a value indicating whether the application is running with administrator privileges. + /// + /// True if running as administrator, false otherwise. + bool IsRunningAsAdministrator(); + + /// + /// Gets a string value from the registry using HKLM. + /// + /// The path to the registry key. + /// The name of the value to retrieve. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The string value, or null if not found or an error occurred. + string? GetStringValue(string keyPath, string valueName, bool useWow6432Node = true); + + /// + /// Gets a string value from the specified registry hive. + /// + /// The path to the registry key. + /// The name of the value to retrieve. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The registry hive to access. + /// The string value, or null if not found or an error occurred. + string? GetStringValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive); + + /// + /// Sets a string value in the registry using HKLM. + /// + /// The path to the registry key. + /// The name of the value to set. + /// The value to set. + /// Whether to use the Wow6432Node (32-bit registry view). + /// True if successful, false otherwise. + bool SetStringValue(string keyPath, string valueName, string value, bool useWow6432Node = true); + + /// + /// Sets a string value in the specified registry hive. + /// + /// The path to the registry key. + /// The name of the value to set. + /// The value to set. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The registry hive to access. + /// True if successful, false otherwise. + bool SetStringValue(string keyPath, string valueName, string value, bool useWow6432Node, RegistryHive hive); + + /// + /// Gets an integer value from the registry using HKLM. + /// + /// The path to the registry key. + /// The name of the value to retrieve. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The integer value, or null if not found or an error occurred. + int? GetIntValue(string keyPath, string valueName, bool useWow6432Node = true); + + /// + /// Gets an integer value from the specified registry hive. + /// + /// The path to the registry key. + /// The name of the value to retrieve. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The registry hive to access. + /// The integer value, or null if not found or an error occurred. + int? GetIntValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive); + + /// + /// Sets an integer value in the registry using HKLM. + /// + /// The path to the registry key. + /// The name of the value to set. + /// The value to set. + /// Whether to use the Wow6432Node (32-bit registry view). + /// True if successful, false otherwise. + bool SetIntValue(string keyPath, string valueName, int value, bool useWow6432Node = true); + + /// + /// Sets an integer value in the specified registry hive. + /// + /// The path to the registry key. + /// The name of the value to set. + /// The value to set. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The registry hive to access. + /// True if successful, false otherwise. + bool SetIntValue(string keyPath, string valueName, int value, bool useWow6432Node, RegistryHive hive); + + /// + /// Deletes a value from the registry using HKLM. + /// + /// The path to the registry key. + /// The name of the value to delete. + /// Whether to use the Wow6432Node (32-bit registry view). + /// True if successful, false otherwise. + bool DeleteValue(string keyPath, string valueName, bool useWow6432Node = true); + + /// + /// Deletes a value from the specified registry hive. + /// + /// The path to the registry key. + /// The name of the value to delete. + /// Whether to use the Wow6432Node (32-bit registry view). + /// The registry hive to access. + /// True if successful, false otherwise. + bool DeleteValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive); +} + +/// +/// Implementation of the registry service. +/// +public class RegistryService(ILogger logger) : IRegistryService +{ + /// + /// Gets a value indicating whether the application is running with administrator privileges. + /// + /// True if running as administrator, false otherwise. + public bool IsRunningAsAdministrator() + { + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to determine if running as administrator"); + return false; + } + } + + /// + public string? GetStringValue(string keyPath, string valueName, bool useWow6432Node = true) + => GetStringValue(keyPath, valueName, useWow6432Node, RegistryHive.LocalMachine); + + /// + public string? GetStringValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, useWow6432Node ? RegistryView.Registry32 : RegistryView.Default); + using var subKey = baseKey.OpenSubKey(keyPath); + return subKey?.GetValue(valueName) as string; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to read registry key {KeyPath}\\{ValueName}", keyPath, valueName); + return null; + } + } + + /// + public bool SetStringValue(string keyPath, string valueName, string value, bool useWow6432Node = true) + => SetStringValue(keyPath, valueName, value, useWow6432Node, RegistryHive.LocalMachine); + + /// + public bool SetStringValue(string keyPath, string valueName, string value, bool useWow6432Node, RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, useWow6432Node ? RegistryView.Registry32 : RegistryView.Default); + using var subKey = baseKey.CreateSubKey(keyPath); // CreateSubKey opens it for write if it exists + subKey.SetValue(valueName, value); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to write registry key {KeyPath}\\{ValueName}", keyPath, valueName); + return false; + } + } + + /// + public int? GetIntValue(string keyPath, string valueName, bool useWow6432Node = true) + => GetIntValue(keyPath, valueName, useWow6432Node, RegistryHive.LocalMachine); + + /// + public int? GetIntValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, useWow6432Node ? RegistryView.Registry32 : RegistryView.Default); + using var subKey = baseKey.OpenSubKey(keyPath); + return subKey?.GetValue(valueName) as int?; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to read registry key {KeyPath}\\{ValueName}", keyPath, valueName); + return null; + } + } + + /// + public bool SetIntValue(string keyPath, string valueName, int value, bool useWow6432Node = true) + => SetIntValue(keyPath, valueName, value, useWow6432Node, RegistryHive.LocalMachine); + + /// + public bool SetIntValue(string keyPath, string valueName, int value, bool useWow6432Node, RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, useWow6432Node ? RegistryView.Registry32 : RegistryView.Default); + using var subKey = baseKey.CreateSubKey(keyPath); + subKey.SetValue(valueName, value, RegistryValueKind.DWord); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to write registry key {KeyPath}\\{ValueName}", keyPath, valueName); + return false; + } + } + + /// + public bool DeleteValue(string keyPath, string valueName, bool useWow6432Node = true) + => DeleteValue(keyPath, valueName, useWow6432Node, RegistryHive.LocalMachine); + + /// + public bool DeleteValue(string keyPath, string valueName, bool useWow6432Node, RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, useWow6432Node ? RegistryView.Registry32 : RegistryView.Default); + using var subKey = baseKey.OpenSubKey(keyPath, true); + if (subKey != null) + { + subKey.DeleteValue(valueName, false); + return true; + } + + return false; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete registry value {KeyPath}\\{ValueName}", keyPath, valueName); + return false; + } + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Windows/Features/ActionSets/UI/ActionSetViewModel.cs b/GenHub/GenHub.Windows/Features/ActionSets/UI/ActionSetViewModel.cs new file mode 100644 index 000000000..c1080e4ba --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/ActionSetViewModel.cs @@ -0,0 +1,371 @@ +namespace GenHub.Windows.Features.ActionSets.UI; + +using System; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +#pragma warning disable S2325 // Methods/properties bound by Avalonia XAML or Command patterns must be instance members + +/// +/// View model for an individual action set. +/// +public partial class ActionSetViewModel( + IActionSet actionSet, + GameInstallation installation, + INotificationService notificationService, + ILogger logger, + Action? onStatusChanged = null, + Action? onBusyChanged = null, + Func? isParentBusy = null) : ObservableObject +{ + /// + /// Gets the underlying action set. + /// + public IActionSet ActionSet { get; } = actionSet; + + /// + /// Gets the title of the action set. + /// + public string Title => ActionSet.Title; + + /// + /// Gets the concise description of the action set. + /// + public string Description => ActionSet.Description; + + /// + /// Gets the detailed description of what the action set does. + /// + public string DetailedDescription => ActionSet.DetailedDescription; + + /// + /// Gets the category of the action set. + /// + public string Category => ActionSet.Category; + + /// + /// Gets a value indicating whether this is a core fix. + /// + public bool IsCore => ActionSet.IsCoreFix; + + /// + /// Gets a value indicating whether this is a crucial fix for game stability. + /// + public bool IsCrucial => ActionSet.IsCrucialFix; + + /// + /// Gets a value indicating whether this fix has a detailed description available. + /// + public bool HasDetailedDescription => !string.IsNullOrWhiteSpace(ActionSet.DetailedDescription); + + [ObservableProperty] + private bool isExpanded; + + [ObservableProperty] + private string lastActionResultDetails = string.Empty; + + [ObservableProperty] + private bool hasActionResultDetails; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanApply))] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] + [NotifyPropertyChangedFor(nameof(StatusColor))] + [NotifyPropertyChangedFor(nameof(StatusBackground))] + [NotifyPropertyChangedFor(nameof(StatusBorder))] + [NotifyCanExecuteChangedFor(nameof(ApplyCommand))] + private bool isApplicable; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanApply))] + [NotifyPropertyChangedFor(nameof(StatusDisplay))] + [NotifyPropertyChangedFor(nameof(StatusColor))] + [NotifyPropertyChangedFor(nameof(StatusBackground))] + [NotifyPropertyChangedFor(nameof(StatusBorder))] + [NotifyCanExecuteChangedFor(nameof(ApplyCommand))] + private bool isApplied; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanApply))] + [NotifyCanExecuteChangedFor(nameof(ApplyCommand))] + [NotifyCanExecuteChangedFor(nameof(ForceApplyCommand))] + [NotifyCanExecuteChangedFor(nameof(CancelApplyCommand))] + private bool isApplying; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanApply))] + [NotifyCanExecuteChangedFor(nameof(ApplyCommand))] + [NotifyCanExecuteChangedFor(nameof(ForceApplyCommand))] + private bool isBatchApplying; + + private CancellationTokenSource? _applyCts; + + /// + /// Gets a value indicating whether the fix can be applied. + /// + public bool CanApply => IsApplicable && !IsApplied && !IsApplying && !IsBatchApplying && !IsParentBusy; + + /// + /// Gets the display status of the action set. + /// + public string StatusDisplay => (IsApplied, IsApplicable) switch + { + (true, _) => "APPLIED", + (false, true) => "NOT APPLIED", + (false, false) => "NOT APPLICABLE", + }; + + /// + /// Gets the color for the status display. + /// + public string StatusColor => (IsApplied, IsApplicable) switch + { + (true, _) => ActionSetConstants.StatusColors.Applied, + (false, true) => ActionSetConstants.StatusColors.Unapplied, + (false, false) => ActionSetConstants.StatusColors.NotApplicable, + }; + + /// + /// Gets the background color for the status badge. + /// + public string StatusBackground => (IsApplied, IsApplicable) switch + { + (true, _) => ActionSetConstants.StatusColors.AppliedBackground, + (false, true) => ActionSetConstants.StatusColors.UnappliedBackground, + (false, false) => ActionSetConstants.StatusColors.NotApplicableBackground, + }; + + /// + /// Gets the border color for the status badge. + /// + public string StatusBorder => (IsApplied, IsApplicable) switch + { + (true, _) => ActionSetConstants.StatusColors.AppliedBorder, + (false, true) => ActionSetConstants.StatusColors.UnappliedBorder, + (false, false) => ActionSetConstants.StatusColors.NotApplicableBorder, + }; + + private bool IsParentBusy => isParentBusy?.Invoke() == true; + + /// + /// Checks the status of the action set (applicable and applied). + /// + /// The cancellation token. + /// A task representing the asynchronous operation. + public async Task CheckStatusAsync(CancellationToken ct = default) + { + try + { + ct.ThrowIfCancellationRequested(); + + logger.LogInformation( + "[GENPATCHER_CHECK_005] Checking status for {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + + var applicable = await ActionSet.IsApplicableAsync(installation, ct); + ct.ThrowIfCancellationRequested(); + + var applied = await ActionSet.IsAppliedAsync(installation, ct); + ct.ThrowIfCancellationRequested(); + + IsApplicable = applicable; + IsApplied = applied; + + logger.LogInformation( + "Status check complete: {Title} - Applicable={Applicable}, Applied={Applied}", + ActionSet.Title, + IsApplicable, + IsApplied); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError( + ex, + "[GENPATCHER_CHECK_006] Failed to check status for {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + } + } + + /// + /// Notifies the UI that execution state has changed across action sets. + /// + public void NotifyExecutionChanged() + { + OnPropertyChanged(nameof(CanApply)); + ApplyCommand.NotifyCanExecuteChanged(); + ForceApplyCommand.NotifyCanExecuteChanged(); + } + + partial void OnIsApplyingChanged(bool value) + { + onBusyChanged?.Invoke(); + } + + private bool CanExecuteApply() => CanApply; + + private bool CanExecuteForceApply() => !IsApplying && !IsBatchApplying && !IsParentBusy; + + private bool CanExecuteCancelApply() => IsApplying; + + [RelayCommand] + private void ToggleExpanded() => IsExpanded = !IsExpanded; + + [RelayCommand(CanExecute = nameof(CanExecuteApply))] + private Task ApplyAsync() => ExecuteApplyAsync(isForce: false); + + [RelayCommand(CanExecute = nameof(CanExecuteForceApply))] + private Task ForceApplyAsync() => ExecuteApplyAsync(isForce: true); + + /// + /// Cancels the ongoing individual fix application if running. + /// + [RelayCommand(CanExecute = nameof(CanExecuteCancelApply))] + private async Task CancelApplyAsync() + { + if (_applyCts != null && !_applyCts.IsCancellationRequested) + { + logger.LogInformation("User cancelled application of {Title} (ID={Id})", ActionSet.Title, ActionSet.Id); + await _applyCts.CancelAsync(); + notificationService.ShowWarning("Cancelling", $"Cancelling application of {ActionSet.Title}..."); + } + } + + private async Task ExecuteApplyAsync(bool isForce) + { + if (IsApplying || IsBatchApplying || IsParentBusy) + { + return; + } + + if (_applyCts != null) + { + await _applyCts.CancelAsync(); + _applyCts.Dispose(); + } + + _applyCts = new CancellationTokenSource(); + var ct = _applyCts.Token; + + try + { + IsApplying = true; + CancelApplyCommand.NotifyCanExecuteChanged(); + + logger.LogInformation( + isForce ? "[GENPATCHER_FIX_013] Starting FORCE application of {Title} (ID={Id}) to {InstallPath}" : "[GENPATCHER_FIX_009] Starting application of {Title} (ID={Id}) to {InstallPath}", + ActionSet.Title, + ActionSet.Id, + installation.InstallationPath); + + var startTime = DateTime.UtcNow; + var result = await ActionSet.ApplyAsync(installation, ct); + var duration = (DateTime.UtcNow - startTime).TotalMilliseconds; + + if (result.Success) + { + HandleApplySuccess(result, isForce, duration); + } + else + { + HandleApplyFailure(result, isForce, duration); + } + } + catch (OperationCanceledException ex) when (ct.IsCancellationRequested) + { + logger.LogWarning(ex, "Application of {Title} was cancelled by user", ActionSet.Title); + notificationService.ShowWarning("Apply Cancelled", $"Application of {ActionSet.Title} was cancelled."); + } + catch (Exception ex) + { + logger.LogError( + ex, + isForce ? "[GENPATCHER_FIX_015] Exception force applying {Title} (ID={Id})" : "[GENPATCHER_FIX_011] Exception applying {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + notificationService.ShowError( + isForce ? "Failed to Force Apply Fix" : "Failed to Apply Fix", + $"Could not apply {ActionSet.Title}: {ex.Message}"); + } + finally + { + try + { + await CheckStatusAsync(CancellationToken.None); + onStatusChanged?.Invoke(); + } + catch (Exception statusEx) + { + logger.LogWarning(statusEx, "Error refreshing status after apply for {Title}", ActionSet.Title); + } + + IsApplying = false; + _applyCts?.Dispose(); + _applyCts = null; + CancelApplyCommand.NotifyCanExecuteChanged(); + } + } + + private void HandleApplySuccess(ActionSetResult result, bool isForce, double duration) + { + string detailsText; + if (result.Details.Count > 0) + { + detailsText = result.FormatDetails(); + } + else if (isForce) + { + detailsText = $"{ActionSet.Title} has been force applied successfully."; + } + else + { + detailsText = $"{ActionSet.Title} has been successfully applied."; + } + + LastActionResultDetails = detailsText; + HasActionResultDetails = true; + + logger.LogInformation( + isForce ? "✓ {Title} force applied successfully in {Duration}ms - {Details}" : "✓ {Title} applied successfully in {Duration}ms - {Details}", + ActionSet.Title, + (int)duration, + result.Details.Count > 0 ? string.Join("; ", result.Details) : "No details provided"); + + notificationService.ShowSuccess( + isForce ? $"Fix Force Applied: {ActionSet.Title}" : $"Fix Applied: {ActionSet.Title}", + detailsText); + } + + private void HandleApplyFailure(ActionSetResult result, bool isForce, double duration) + { + var detailsText = result.Details.Count > 0 + ? result.FormatDetails() + : result.ErrorMessage ?? "Unknown error occurred."; + + LastActionResultDetails = detailsText; + HasActionResultDetails = true; + + logger.LogError( + isForce ? "✗ [GENPATCHER_FIX_014] {Title} force apply failed in {Duration}ms - {Error} - {Details}" : "✗ [GENPATCHER_FIX_010] {Title} failed in {Duration}ms - {Error} - {Details}", + ActionSet.Title, + (int)duration, + result.ErrorMessage ?? "Unknown error", + result.Details.Count > 0 ? string.Join("; ", result.Details) : "No details"); + + notificationService.ShowError( + $"Fix Failed: {ActionSet.Title}", + detailsText); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml new file mode 100644 index 000000000..094047d3b --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml @@ -0,0 +1,376 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml.cs b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml.cs new file mode 100644 index 000000000..2e896a828 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml.cs @@ -0,0 +1,47 @@ +namespace GenHub.Windows.Features.ActionSets.UI; + +using System; +using System.Diagnostics; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +/// +/// View for the GenPatcher tool. +/// +public partial class GenPatcherToolView : UserControl +{ + /// + /// Initializes a new instance of the class. + /// + public GenPatcherToolView() + { + InitializeComponent(); + + // Trigger initialization when the view is actually loaded + AttachedToVisualTree += OnAttachedToVisualTree; + } + + private async void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + // Only initialize once + AttachedToVisualTree -= OnAttachedToVisualTree; + + if (DataContext is GenPatcherViewModel vm) + { + try + { + await vm.InitializeAsync(); + } + catch (Exception ex) + { + Debug.WriteLine($"[GenPatcherToolView] Initialization error: {ex.Message}"); + } + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherViewModel.cs b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherViewModel.cs new file mode 100644 index 000000000..1b2427e40 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherViewModel.cs @@ -0,0 +1,736 @@ +namespace GenHub.Windows.Features.ActionSets.UI; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +#pragma warning disable S2325 // Methods/properties bound by Avalonia XAML or Command patterns must be instance members + +/// +/// ViewModel for the GenPatcher feature. +/// +public partial class GenPatcherViewModel( + IActionSetOrchestrator orchestrator, + IGameInstallationDetector installationDetector, + IRegistryService registryService, + INotificationService notificationService, + IDialogService dialogService, + ILogger logger) : ObservableObject +{ + [ObservableProperty] + private ObservableCollection availableInstallations = []; + + [ObservableProperty] + private GameInstallation? selectedInstallation; + + [ObservableProperty] + private ObservableCollection actionSets = []; + + [ObservableProperty] + private ObservableCollection filteredActionSets = []; + + [ObservableProperty] + private string searchQuery = string.Empty; + + [ObservableProperty] + private string selectedCategory = "All"; + + [ObservableProperty] + private string selectedStatus = "All"; + + [ObservableProperty] + private int totalFixesCount; + + [ObservableProperty] + private int applicableFixesCount; + + [ObservableProperty] + private int appliedFixesCount; + + [ObservableProperty] + private int unappliedFixesCount; + + [ObservableProperty] + private double progressPercentage; + + [ObservableProperty] + private string progressSummaryText = string.Empty; + + [ObservableProperty] + private int allCategoryCount; + + [ObservableProperty] + private int coreCategoryCount; + + [ObservableProperty] + private int compatibilityCategoryCount; + + [ObservableProperty] + private int multiplayerCategoryCount; + + [ObservableProperty] + private int qolCategoryCount; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(ApplyAllFixesCommand))] + [NotifyCanExecuteChangedFor(nameof(CancelBatchApplyCommand))] + private bool isBatchApplying; + + private CancellationTokenSource? _batchCts; + private CancellationTokenSource? _refreshCts; + private int _refreshVersion; + private bool _isRevertingSelection; + + /// + /// Gets a value indicating whether the user can change the target installation (not busy). + /// + public bool CanChangeInstallation => !IsBatchApplying && ActionSets.All(x => !x.IsApplying); + + /// + /// Initializes the ViewModel asynchronously. + /// + /// A task representing the asynchronous operation. + public async Task InitializeAsync() + { + logger.LogInformation("[GENPATCHER_INIT_001] GenPatcher tool opened by user"); + + var isAdmin = await Task.Run(() => registryService.IsRunningAsAdministrator(), CancellationToken.None); + var osVersion = Environment.OSVersion.VersionString; + var dotnetVersion = Environment.Version.ToString(); + + logger.LogInformation( + "System Info - OS: {OsVersion}, .NET: {DotNetVersion}, Admin: {IsAdmin}", + osVersion, + dotnetVersion, + isAdmin); + + if (!isAdmin) + { + logger.LogWarning("GenPatcher running without administrator privileges - some fixes may fail"); + notificationService.ShowWarning( + "Administrator Rights Required", + "Please restart GenHub as Administrator to ensure GenPatcher can apply registry-based fixes."); + } + + await LoadFixesCommand.ExecuteAsync(null); + } + + private static bool MatchesCategory(ActionSetViewModel vm, string category) => + string.IsNullOrEmpty(category) || + string.Equals(category, "All", StringComparison.OrdinalIgnoreCase) || + string.Equals(vm.Category, category, StringComparison.OrdinalIgnoreCase); + + private static bool MatchesStatus(ActionSetViewModel vm, string status) + { + if (string.IsNullOrEmpty(status) || string.Equals(status, "All", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return status switch + { + "Applied" => vm.IsApplied, + "Not Applied" => vm.IsApplicable && !vm.IsApplied, + "Not Applicable" => !vm.IsApplicable, + _ => true, + }; + } + + private static bool MatchesSearch(ActionSetViewModel vm, string query) => + string.IsNullOrEmpty(query) || + (!string.IsNullOrEmpty(vm.Title) && vm.Title.Contains(query, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(vm.Description) && vm.Description.Contains(query, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(vm.DetailedDescription) && vm.DetailedDescription.Contains(query, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(vm.Category) && vm.Category.Contains(query, StringComparison.OrdinalIgnoreCase)); + + private static int GetSortPriority(ActionSetViewModel vm) + { + // 0: NOT APPLIED (applicable and needs fix) -> top + // 1: APPLIED (applicable and already fixed) + // 2: NOT APPLICABLE (not applicable to this game installation) + if (vm.IsApplicable && !vm.IsApplied) + { + return 0; + } + + if (vm.IsApplicable && vm.IsApplied) + { + return 1; + } + + return 2; + } + + private bool CanExecuteCancelBatchApply() => IsBatchApplying; + + /// + /// Cancels the ongoing batch fix application if running. + /// + [RelayCommand(CanExecute = nameof(CanExecuteCancelBatchApply))] + private void CancelBatchApply() + { + if (_batchCts != null && !_batchCts.IsCancellationRequested) + { + logger.LogInformation("User cancelled batch fix application"); + _batchCts.Cancel(); + notificationService.ShowWarning("Cancelling", "Cancelling batch application after the current fix completes..."); + } + } + + partial void OnSelectedInstallationChanged(GameInstallation? oldValue, GameInstallation? newValue) + { + if (_isRevertingSelection) + { + return; + } + + if (newValue == null) + { + return; + } + + if (!CanChangeInstallation) + { + logger.LogWarning("Cannot switch installation while fix is applying. Reverting to previous installation."); + if (oldValue != null) + { + _isRevertingSelection = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + try + { + SelectedInstallation = oldValue; + } + finally + { + _isRevertingSelection = false; + } + }); + } + + return; + } + + ApplyAllFixesCommand.NotifyCanExecuteChanged(); + logger.LogInformation("Selected installation changed to: {InstallType} at {Path}", newValue.InstallationType, newValue.InstallationPath); + _ = RefreshFixesForInstallationAsync(newValue); + } + + partial void OnIsBatchApplyingChanged(bool value) + { + OnPropertyChanged(nameof(CanChangeInstallation)); + foreach (var vm in ActionSets) + { + vm.IsBatchApplying = value; + } + } + + [RelayCommand] + private async Task LoadFixesAsync() + { + try + { + logger.LogInformation("[GENPATCHER_LOAD_002] Detecting game installations..."); + notificationService.ShowInfo( + "Loading GenPatcher", + "Detecting game installations and loading available fixes..."); + + var result = await Task.Run(() => installationDetector.DetectInstallationsAsync(CancellationToken.None), CancellationToken.None); + if (!result.Success) + { + var errorSummary = result.Errors.Count > 0 ? string.Join("; ", result.Errors) : "Installation detection failed."; + logger.LogError("[GENPATCHER_LOAD_003] Failed to detect game installations: {Error}", errorSummary); + notificationService.ShowError( + "Detection Failed", + $"Failed to detect game installations: {errorSummary}"); + return; + } + + var detected = result.Items; + var validInstallations = detected + .Where(x => x.InstallationType != GameInstallationType.Unknown) + .ToList(); + + logger.LogInformation("Found {Count} valid game installation(s)", validInstallations.Count); + foreach (var inst in validInstallations) + { + logger.LogDebug( + "Installation: {InstallType} at {Path}", + inst.InstallationType, + inst.InstallationPath); + } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + AvailableInstallations.Clear(); + foreach (var inst in validInstallations) + { + AvailableInstallations.Add(inst); + } + }); + + if (validInstallations.Count == 0) + { + logger.LogError("[GENPATCHER_LOAD_003] No valid game installation found for GenPatcher"); + notificationService.ShowError( + "No Game Installation Found", + "Please ensure Command & Conquer Generals or Zero Hour is installed."); + return; + } + + if (SelectedInstallation == null || !validInstallations.Contains(SelectedInstallation)) + { + SelectedInstallation = validInstallations[0]; + } + else + { + await RefreshFixesForInstallationAsync(SelectedInstallation); + } + } + catch (Exception ex) + { + logger.LogError(ex, "[GENPATCHER_LOAD_004] Failed to load fixes"); + notificationService.ShowError( + "Failed to Load Fixes", + $"An error occurred while loading fixes: {ex.Message}"); + } + } + + private async Task RefreshFixesForInstallationAsync(GameInstallation installation) + { + var version = Interlocked.Increment(ref _refreshVersion); + var ct = await ResetRefreshCancellationTokenAsync(); + + try + { + logger.LogInformation( + "Using installation: {InstallType} at {Path} (refresh version {Version})", + installation.InstallationType, + installation.InstallationPath, + version); + + var sortedVms = await LoadAndSortActionSetViewModelsAsync(installation, ct); + + if (!IsRefreshValid(version, installation, ct)) + { + logger.LogDebug("Refresh version {Version} was superseded or cancelled", version); + return; + } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => PopulateActionSets(sortedVms, version, installation, ct)); + + if (!IsRefreshValid(version, installation, ct)) + { + logger.LogDebug("Refresh version {Version} was superseded or cancelled", version); + return; + } + + LogRefreshCompletionSummary(installation); + } + catch (OperationCanceledException ex) + { + logger.LogDebug(ex, "Refresh fixes for installation {Path} was cancelled (version {Version})", installation.InstallationPath, version); + } + catch (Exception ex) + { + HandleRefreshException(ex, installation, version, ct); + } + } + + private async Task ResetRefreshCancellationTokenAsync() + { + if (_refreshCts != null) + { + await _refreshCts.CancelAsync(); + _refreshCts.Dispose(); + } + + _refreshCts = new CancellationTokenSource(); + return _refreshCts.Token; + } + + private bool IsRefreshValid(int version, GameInstallation installation, CancellationToken ct) => + !ct.IsCancellationRequested && version == _refreshVersion && SelectedInstallation == installation; + + private void PopulateActionSets(List sortedVms, int version, GameInstallation installation, CancellationToken ct) + { + if (!IsRefreshValid(version, installation, ct)) + { + return; + } + + ActionSets.Clear(); + foreach (var vm in sortedVms) + { + ActionSets.Add(vm); + logger.LogInformation( + "[{Title}] ID={Id}, IsCore={IsCore}, Applicable={Applicable}, Applied={Applied}", + vm.ActionSet.Title, + vm.ActionSet.Id, + vm.IsCore, + vm.IsApplicable, + vm.IsApplied); + } + + ApplyFilter(); + ApplyAllFixesCommand.NotifyCanExecuteChanged(); + } + + private void HandleRefreshException(Exception ex, GameInstallation installation, int version, CancellationToken ct) + { + if (version == _refreshVersion && !ct.IsCancellationRequested) + { + logger.LogError(ex, "Error refreshing fixes for installation {Path}", installation.InstallationPath); + notificationService.ShowError( + "Failed to Load Fixes", + $"An error occurred while loading fixes: {ex.Message}"); + } + else + { + logger.LogDebug(ex, "Superseded refresh encountered an exception for installation {Path}", installation.InstallationPath); + } + } + + private async Task> LoadAndSortActionSetViewModelsAsync(GameInstallation installation, CancellationToken ct) + { + var fixes = orchestrator.GetAllActionSets(); + logger.LogInformation("Loading {Count} action sets...", fixes.Count); + + // Parallelize status checks to prevent UI blocking + var tasks = fixes.Select(fix => Task.Run( + async () => + { + ct.ThrowIfCancellationRequested(); + var vm = new ActionSetViewModel( + fix, + installation, + notificationService, + logger, + () => Avalonia.Threading.Dispatcher.UIThread.Post(SortActionSets), + () => Avalonia.Threading.Dispatcher.UIThread.Post(NotifyExecutionStateChanged), + () => IsBatchApplying || ActionSets.Any(x => !string.Equals(x.ActionSet.Id, fix.Id, StringComparison.OrdinalIgnoreCase) && x.IsApplying)) + { + IsBatchApplying = IsBatchApplying, + }; + await vm.CheckStatusAsync(ct); + return vm; + }, + ct)).ToList(); + + var loadedVms = await Task.WhenAll(tasks); + + return loadedVms + .OrderBy(GetSortPriority) + .ThenByDescending(vm => vm.IsCore) + .ThenBy(vm => vm.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private void LogRefreshCompletionSummary(GameInstallation installation) + { + var applicableCount = ActionSets.Count(x => x.IsApplicable); + var appliedAndApplicableCount = ActionSets.Count(x => x.IsApplicable && x.IsApplied); + var totalAppliedCount = ActionSets.Count(x => x.IsApplied); + var notApplicableCount = ActionSets.Count(x => !x.IsApplicable); + var coreCount = ActionSets.Count(x => x.IsCore); + + logger.LogInformation( + "Load complete - Total: {Total}, Core: {Core}, Applicable: {Applicable}, Applied (Total): {AppliedTotal}, Applied (Applicable): {AppliedApplicable}, NotApplicable: {NotApplicable}", + ActionSets.Count, + coreCount, + applicableCount, + totalAppliedCount, + appliedAndApplicableCount, + notApplicableCount); + + notificationService.ShowSuccess( + "GenPatcher Loaded", + $"Successfully loaded {ActionSets.Count} fixes for {installation.InstallationType}.\nApplied: {appliedAndApplicableCount} / {applicableCount} applicable fixes."); + } + + private bool CanExecuteApplyAllFixes() => !IsBatchApplying && SelectedInstallation != null && ActionSets.All(x => !x.IsApplying); + + private void NotifyExecutionStateChanged() + { + OnPropertyChanged(nameof(CanChangeInstallation)); + ApplyAllFixesCommand.NotifyCanExecuteChanged(); + foreach (var vm in ActionSets) + { + vm.NotifyExecutionChanged(); + } + } + + [RelayCommand(CanExecute = nameof(CanExecuteApplyAllFixes))] + private async Task ApplyAllFixesAsync() + { + if (IsBatchApplying) + { + return; + } + + if (SelectedInstallation == null) + { + logger.LogError("[GENPATCHER_APPLY_004] Cannot apply fixes - no installation selected"); + notificationService.ShowError("No Installation Selected", "Please select a game installation before applying fixes."); + return; + } + + var targetInstallation = SelectedInstallation; + + if (!registryService.IsRunningAsAdministrator()) + { + logger.LogWarning("[GENPATCHER_APPLY_005] Apply batch rejected - not running as administrator"); + notificationService.ShowError( + "Administrator Rights Required", + "Administrator privileges required for 'Apply Recommended'. Please restart GenHub as Administrator."); + return; + } + + var confirmed = await dialogService.ShowConfirmationAsync( + ActionSetConstants.Dialogs.ApplyAllConfirmationTitle, + $"Are you sure you want to apply all recommended fixes for {targetInstallation.InstallationType}?\n\nThis will modify game files and configuration settings at:\n{targetInstallation.InstallationPath}", + confirmText: ActionSetConstants.Dialogs.ApplyAllConfirmButtonText, + cancelText: ActionSetConstants.Dialogs.ApplyAllCancelButtonText); + + if (!confirmed) + { + logger.LogInformation("Batch fix application cancelled by user at confirmation prompt"); + return; + } + + if (_batchCts != null) + { + await _batchCts.CancelAsync(); + _batchCts.Dispose(); + } + + _batchCts = new CancellationTokenSource(); + var ct = _batchCts.Token; + + IsBatchApplying = true; + + try + { + var applicableFixes = await GetApplicableCoreFixesAsync(targetInstallation, ct); + if (applicableFixes.Count == 0) + { + var alreadyApplied = ActionSets.Count(x => x.IsApplied); + var totalSets = ActionSets.Count; + + logger.LogInformation("No fixes to apply - {Applied}/{Total} already applied", alreadyApplied, totalSets); + notificationService.ShowInfo( + "No Fixes to Apply", + $"All {alreadyApplied}/{totalSets} applicable fixes are already applied for {targetInstallation.InstallationType}."); + return; + } + + logger.LogInformation( + "[GENPATCHER_APPLY_006] Starting batch application of {Count} fixes for {InstallType} ({Path}) via orchestrator: {FixList}", + applicableFixes.Count, + targetInstallation.InstallationType, + targetInstallation.InstallationPath, + string.Join(", ", applicableFixes.Select(f => f.Id))); + + notificationService.ShowInfo( + "Applying Fixes", + $"Applying {applicableFixes.Count} recommended fix(es) to {targetInstallation.InstallationType} ({targetInstallation.InstallationPath})..."); + + var startTime = DateTime.UtcNow; + var batchResult = await orchestrator.ApplyActionSetsAsync(targetInstallation, applicableFixes, ct); + var totalDuration = (DateTime.UtcNow - startTime).TotalSeconds; + + await RefreshAllActionSetStatusesAsync(); + DisplayBatchResults(batchResult, targetInstallation, applicableFixes.Count, totalDuration); + } + catch (OperationCanceledException ex) + { + logger.LogWarning(ex, "Batch fix application was cancelled by user"); + notificationService.ShowWarning("Batch Cancelled", "Batch fix application was cancelled."); + } + catch (Exception ex) + { + logger.LogError(ex, "Fatal error during batch fix application"); + notificationService.ShowError("Batch Apply Error", $"An error occurred: {ex.Message}"); + } + finally + { + IsBatchApplying = false; + _batchCts?.Dispose(); + _batchCts = null; + } + } + + private async Task> GetApplicableCoreFixesAsync(GameInstallation targetInstallation, CancellationToken ct) + { + var coreFixes = await orchestrator.GetApplicableCoreFixesAsync(targetInstallation, ct); + var coreFixIds = new HashSet(coreFixes.Select(f => f.Id), StringComparer.OrdinalIgnoreCase); + + return ActionSets + .Where(vm => vm.IsApplicable && !vm.IsApplied && coreFixIds.Contains(vm.ActionSet.Id)) + .Select(vm => vm.ActionSet) + .ToList(); + } + + private async Task RefreshAllActionSetStatusesAsync() + { + logger.LogInformation("Refreshing fix status after batch application..."); + foreach (var vm in ActionSets) + { + try + { + await vm.CheckStatusAsync(CancellationToken.None); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error refreshing status for {Title}", vm.ActionSet.Title); + } + } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(SortActionSets); + } + + private void DisplayBatchResults( + OperationResult batchResult, + GameInstallation targetInstallation, + int totalApplicable, + double totalDuration) + { + int successCount = batchResult.Data; + int errorCount = batchResult.Errors.Count; + int notAttemptedCount = Math.Max(0, totalApplicable - successCount - errorCount); + + if (batchResult.Success) + { + logger.LogInformation( + "Batch complete in {Duration:F1}s - {Success}/{Total} successful for {InstallType}", + totalDuration, + successCount, + totalApplicable, + targetInstallation.InstallationType); + + notificationService.ShowSuccess( + "All Fixes Applied Successfully", + $"✓ Successfully applied all {successCount} fix(es) to {targetInstallation.InstallationType} ({targetInstallation.InstallationPath}).\n\nYour game installation has been optimized!"); + } + else + { + var errorDetails = string.Join("\n", batchResult.Errors); + logger.LogWarning("Batch completed with errors: {Errors}", errorDetails); + var failureSummary = notAttemptedCount > 0 + ? $"Target: {targetInstallation.InstallationType} ({targetInstallation.InstallationPath})\n✓ Successfully applied: {successCount}\n✗ Failed: {errorCount}\n⚠ Not attempted: {notAttemptedCount}\n\nErrors:\n{errorDetails}" + : $"Target: {targetInstallation.InstallationType} ({targetInstallation.InstallationPath})\n✓ Successfully applied: {successCount}\n✗ Failed: {errorCount}\n\nErrors:\n{errorDetails}"; + + notificationService.ShowError( + $"Fixes Completed with Errors ({successCount}/{totalApplicable} successful)", + failureSummary); + } + } + + partial void OnSearchQueryChanged(string value) => ApplyFilter(); + + partial void OnSelectedCategoryChanged(string value) => ApplyFilter(); + + partial void OnSelectedStatusChanged(string value) => ApplyFilter(); + + [RelayCommand] + private void SetCategory(string category) + { + SelectedCategory = category; + } + + [RelayCommand] + private void SetStatusFilter(string status) + { + SelectedStatus = status; + } + + [RelayCommand] + private void ClearSearch() + { + SearchQuery = string.Empty; + } + + private void ApplyFilter() + { + var query = SearchQuery.Trim(); + var category = SelectedCategory; + var status = SelectedStatus; + + var filtered = ActionSets + .Where(x => MatchesCategory(x, category) && MatchesStatus(x, status) && MatchesSearch(x, query)) + .ToList(); + + FilteredActionSets.Clear(); + foreach (var item in filtered) + { + FilteredActionSets.Add(item); + } + + UpdateMetrics(); + } + + private void UpdateMetrics() + { + TotalFixesCount = ActionSets.Count; + ApplicableFixesCount = ActionSets.Count(x => x.IsApplicable); + AppliedFixesCount = ActionSets.Count(x => x.IsApplicable && x.IsApplied); + UnappliedFixesCount = ActionSets.Count(x => x.IsApplicable && !x.IsApplied); + + ProgressPercentage = ApplicableFixesCount > 0 + ? (double)AppliedFixesCount / ApplicableFixesCount * 100.0 + : 0.0; + + ProgressSummaryText = $"{AppliedFixesCount} of {ApplicableFixesCount} applied"; + + AllCategoryCount = ActionSets.Count; + CoreCategoryCount = ActionSets.Count(x => string.Equals(x.Category, ActionSetConstants.Categories.CoreAndStability, StringComparison.OrdinalIgnoreCase)); + CompatibilityCategoryCount = ActionSets.Count(x => string.Equals(x.Category, ActionSetConstants.Categories.Compatibility, StringComparison.OrdinalIgnoreCase)); + MultiplayerCategoryCount = ActionSets.Count(x => string.Equals(x.Category, ActionSetConstants.Categories.Multiplayer, StringComparison.OrdinalIgnoreCase)); + QolCategoryCount = ActionSets.Count(x => string.Equals(x.Category, ActionSetConstants.Categories.QualityOfLife, StringComparison.OrdinalIgnoreCase)); + } + + private void SortActionSets() + { + var sorted = ActionSets + .OrderBy(GetSortPriority) + .ThenByDescending(vm => vm.IsCore) + .ThenBy(vm => vm.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var isDifferent = false; + for (var i = 0; i < sorted.Count; i++) + { + if (!ReferenceEquals(ActionSets[i], sorted[i])) + { + isDifferent = true; + break; + } + } + + if (isDifferent) + { + ActionSets.Clear(); + foreach (var vm in sorted) + { + ActionSets.Add(vm); + } + } + + ApplyFilter(); + } +} diff --git a/GenHub/GenHub.Windows/Features/Shortcuts/WindowsShortcutService.cs b/GenHub/GenHub.Windows/Features/Shortcuts/WindowsShortcutService.cs index 98c51d4c0..248d606e7 100644 --- a/GenHub/GenHub.Windows/Features/Shortcuts/WindowsShortcutService.cs +++ b/GenHub/GenHub.Windows/Features/Shortcuts/WindowsShortcutService.cs @@ -107,6 +107,33 @@ public string GetShortcutPath(GameProfile profile, string? shortcutName = null) return Path.Combine(desktopPath, $"{AppConstants.AppName}-{name}.lnk"); } + /// + public Task> CreateShortcutAsync( + string shortcutPath, + string targetPath, + string? arguments = null, + string? workingDirectory = null, + string? description = null, + string? iconPath = null) + { + try + { + var directory = Path.GetDirectoryName(shortcutPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + CreateShortcut(shortcutPath, targetPath, arguments, workingDirectory, description, iconPath); + return Task.FromResult(OperationResult.CreateSuccess(true)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create shortcut at {ShortcutPath}", shortcutPath); + return Task.FromResult(OperationResult.CreateFailure($"Failed to create shortcut: {ex.Message}")); + } + } + /// /// Creates a Windows shortcut (.lnk file) using COM interop. /// diff --git a/GenHub/GenHub.Windows/GenHub.Windows.csproj b/GenHub/GenHub.Windows/GenHub.Windows.csproj index 604dd4651..e556b6666 100644 --- a/GenHub/GenHub.Windows/GenHub.Windows.csproj +++ b/GenHub/GenHub.Windows/GenHub.Windows.csproj @@ -11,6 +11,10 @@ true + + + + @@ -21,6 +25,7 @@ + diff --git a/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs b/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs index bbe90325a..fa867ec3a 100644 --- a/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs +++ b/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs @@ -1,13 +1,17 @@ -using System; -using System.Runtime.Versioning; +using GenHub.Core.Features.ActionSets; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Interfaces.Shortcuts; using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Tools; using GenHub.Core.Interfaces.Workspace; using GenHub.Features.GameSettings; using GenHub.Features.Workspace; +using GenHub.Windows.Features.ActionSets; +using GenHub.Windows.Features.ActionSets.Fixes; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using GenHub.Windows.Features.ActionSets.UI; using GenHub.Windows.Features.GitHub.Services; using GenHub.Windows.Features.Shortcuts; using GenHub.Windows.Features.Workspace; @@ -29,6 +33,10 @@ public static class WindowsServicesModule /// The service collection for chaining. public static IServiceCollection AddWindowsServices(this IServiceCollection services) { + // Add HttpClient for patches that download content + services.AddHttpClient(); + services.AddHttpClient("Downloader"); + // Register Windows-specific services services.AddSingleton(); services.AddSingleton(); @@ -45,6 +53,57 @@ public static IServiceCollection AddWindowsServices(this IServiceCollection serv return new WindowsFileOperationsService(baseService, casService, logger); }); + // Register ActionSet Infrastructure + services.AddSingleton(); + services.AddSingleton(); + + // Register ActionSets + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Network Optimization Fixes + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // NOTE: GenPatcherContentActionSetProvider removed - content stubs were non-functional. + // Content from GenPatcherContentRegistry is available in the Downloads UI. + + // Register GenPatcher Tool + services.AddSingleton(); + services.AddSingleton(); + return services; } } diff --git a/GenHub/GenHub/Assets/Icons/genpatcher-icon.png b/GenHub/GenHub/Assets/Icons/genpatcher-icon.png new file mode 100644 index 000000000..eb7935970 Binary files /dev/null and b/GenHub/GenHub/Assets/Icons/genpatcher-icon.png differ diff --git a/GenHub/GenHub/Assets/Logos/genpatcher-logo.png b/GenHub/GenHub/Assets/Logos/genpatcher-logo.png new file mode 100644 index 000000000..eb7935970 Binary files /dev/null and b/GenHub/GenHub/Assets/Logos/genpatcher-logo.png differ diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs index f166b9c46..0466768a0 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs @@ -31,6 +31,14 @@ public class CommunityOutpostResolver( IProviderDefinitionLoader providerLoader, ILogger logger) : IContentResolver { + private sealed record ManifestMetadataContext( + ContentSearchResult DiscoveredItem, + GenPatcherContentMetadata ContentMetadata, + string ContentCode, + string Filename, + IReadOnlyList MirrorUrls, + long FileSize); + /// public string ResolverId => CommunityOutpostConstants.PublisherId; @@ -82,10 +90,16 @@ public Task> ResolveAsync( var contentMetadata = GenPatcherContentRegistry.GetMetadata(contentCode); // Determine filename from URL or content code + if (string.IsNullOrEmpty(discoveredItem.SourceUrl)) + { + return Task.FromResult(OperationResult.CreateFailure( + "SourceUrl cannot be null or empty for Community Outpost content")); + } + if (!Uri.TryCreate(discoveredItem.SourceUrl, UriKind.Absolute, out var downloadUri)) { - throw new InvalidOperationException( - "SourceUrl must be a valid absolute URI for Community Outpost content"); + return Task.FromResult(OperationResult.CreateFailure( + "SourceUrl must be a valid absolute URI for Community Outpost content")); } var filename = DetermineFilename(downloadUri, contentCode); @@ -138,26 +152,7 @@ public Task> ResolveAsync( .WithInstallationInstructions(WorkspaceConstants.DefaultWorkspaceStrategy); // Add dependencies based on content type and category - var dependencies = contentMetadata.GetDependencies(); - foreach (var dependency in dependencies) - { - manifest.AddDependency( - id: dependency.Id, - name: dependency.Name, - dependencyType: dependency.DependencyType, - installBehavior: dependency.InstallBehavior, - minVersion: dependency.MinVersion ?? string.Empty, - maxVersion: dependency.MaxVersion ?? string.Empty, - compatibleVersions: dependency.CompatibleVersions, - isExclusive: GenPatcherDependencyBuilder.IsCategoryExclusive(contentMetadata.Category), - conflictsWith: dependency.ConflictsWith); - - logger.LogDebug( - "Added dependency {DepName} ({DepType}) to manifest for {ContentCode}", - dependency.Name, - dependency.DependencyType, - contentCode); - } + PopulateDependencies(manifest, contentMetadata, contentCode); // Add the file as a remote download manifest.AddRemoteFileAsync( @@ -168,72 +163,76 @@ public Task> ResolveAsync( // Store additional metadata in the manifest for the deliverer var builtManifest = manifest.Build(); + ApplyBuiltManifestMetadata( + builtManifest, + new ManifestMetadataContext( + discoveredItem, + contentMetadata, + contentCode, + filename, + mirrorUrls, + fileSize)); - // Store the install target from content metadata - builtManifest.InstallationInstructions ??= new InstallationInstructions(); + logger.LogInformation( + "Successfully resolved Community Outpost manifest: {ManifestId} for {ContentCode} ({Category})", + builtManifest.Id, + contentCode, + category); - // Add custom properties to track mirrors and archive type - builtManifest.Metadata ??= new ContentMetadata(); + return Task.FromResult(OperationResult.CreateSuccess(builtManifest)); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to resolve Community Outpost content: {Name}", + discoveredItem.Name); + return Task.FromResult(OperationResult.CreateFailure( + $"Failed to resolve content '{discoveredItem.Name}': {ex.Message}")); + } + } - // Store mirror URLs in metadata for fallback support during delivery - if (mirrorUrls.Count > 1) - { - builtManifest.Metadata.Tags ??= []; - builtManifest.Metadata.Tags.Add($"mirrors:{mirrorUrls.Count}"); - } + private static void ApplyBuiltManifestMetadata( + ContentManifest builtManifest, + ManifestMetadataContext context) + { + builtManifest.InstallationInstructions ??= new InstallationInstructions(); + builtManifest.Metadata ??= new ContentMetadata(); - // Store the content code for the factory to use - builtManifest.Metadata.Tags ??= []; - builtManifest.Metadata.Tags.Add($"contentCode:{contentCode}"); - builtManifest.Metadata.Tags.Add($"installTarget:{contentMetadata.InstallTarget}"); + builtManifest.Metadata.Tags ??= []; + if (context.MirrorUrls.Count > 1) + { + builtManifest.Metadata.Tags.Add($"mirrors:{context.MirrorUrls.Count}"); + } - // Mark file as 7z archive if it's a .dat file - if (filename.EndsWith(CommunityOutpostConstants.DatFileExtension, StringComparison.OrdinalIgnoreCase)) - { - foreach (var file in builtManifest.Files) - { - if (file.RelativePath == filename) - { - file.SourcePath = "archive:7z"; - file.InstallTarget = contentMetadata.InstallTarget; - } - } - } + builtManifest.Metadata.Tags.Add($"contentCode:{context.ContentCode}"); + builtManifest.Metadata.Tags.Add($"installTarget:{context.ContentMetadata.InstallTarget}"); - // Update file size if available - if (fileSize > 0 && builtManifest.Files.Count > 0) + if (context.Filename.EndsWith(CommunityOutpostConstants.DatFileExtension, StringComparison.OrdinalIgnoreCase)) + { + foreach (var file in builtManifest.Files.Where(f => f.RelativePath == context.Filename)) { - builtManifest.Files[0].Size = fileSize; + file.SourcePath = "archive:7z"; + file.InstallTarget = context.ContentMetadata.InstallTarget; } + } - // Override the display name to be more user-friendly - builtManifest.Name = discoveredItem.Name ?? contentMetadata.DisplayName; - - // For community-patch, prioritize discoveredItem.Version (dynamic date from legi.cc/patch) - // over static metadata version which may be null/empty - if (contentCode == "community-patch" && !string.IsNullOrEmpty(discoveredItem.Version)) - { - builtManifest.Version = discoveredItem.Version; - } - else - { - builtManifest.Version = !string.IsNullOrEmpty(contentMetadata.Version) - ? contentMetadata.Version - : discoveredItem.Version; - } + if (context.FileSize > 0 && builtManifest.Files.Count > 0) + { + builtManifest.Files[0].Size = context.FileSize; + } - logger.LogInformation( - "Successfully resolved Community Outpost manifest: {ManifestId} for {ContentCode} ({Category})", - builtManifest.Id, - contentCode, - category); + builtManifest.Name = context.DiscoveredItem.Name ?? context.ContentMetadata.DisplayName; - return Task.FromResult(OperationResult.CreateSuccess(builtManifest)); + if (context.ContentCode == "community-patch" && !string.IsNullOrEmpty(context.DiscoveredItem.Version)) + { + builtManifest.Version = context.DiscoveredItem.Version; } - catch (Exception ex) + else { - logger.LogError(ex, "Failed to resolve Community Outpost content"); - return Task.FromResult(OperationResult.CreateFailure($"Resolution failed: {ex.Message}")); + builtManifest.Version = !string.IsNullOrEmpty(context.ContentMetadata.Version) + ? context.ContentMetadata.Version + : context.DiscoveredItem.Version; } } @@ -350,7 +349,7 @@ private static List BuildTags(ContentSearchResult item, GenPatcherConten /// private static string GetMetadataValue(ContentSearchResult item, string key, string defaultValue) { - if (item.ResolverMetadata?.TryGetValue(key, out var value) == true) + if (item.ResolverMetadata is { } metadata && metadata.TryGetValue(key, out var value)) { return value; } @@ -383,6 +382,33 @@ private static string DetermineFilename(Uri downloadUri, string contentCode) return $"{contentCode}{CommunityOutpostConstants.DatFileExtension}"; } + private void PopulateDependencies( + IContentManifestBuilder manifest, + GenPatcherContentMetadata contentMetadata, + string contentCode) + { + var dependencies = contentMetadata.GetDependencies(); + foreach (var dependency in dependencies) + { + manifest.AddDependency( + id: dependency.Id, + name: dependency.Name, + dependencyType: dependency.DependencyType, + installBehavior: dependency.InstallBehavior, + minVersion: dependency.MinVersion ?? string.Empty, + maxVersion: dependency.MaxVersion ?? string.Empty, + compatibleVersions: dependency.CompatibleVersions, + isExclusive: GenPatcherDependencyBuilder.IsCategoryExclusive(contentMetadata.Category), + conflictsWith: dependency.ConflictsWith); + + logger.LogDebug( + "Added dependency {DepName} ({DepType}) to manifest for {ContentCode}", + dependency.Name, + dependency.DependencyType, + contentCode); + } + } + /// /// Gets the list of mirror URLs from the search result metadata. /// @@ -400,4 +426,4 @@ private IReadOnlyList GetMirrorUrls(ContentSearchResult item) return []; } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs index 9de72fb7b..2f6ec5639 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs @@ -28,8 +28,6 @@ public partial class GenPatcherDatCatalogParser(ILogger _logger = logger; - /// public string CatalogFormat => CommunityOutpostCatalogConstants.CatalogFormat; @@ -45,7 +43,7 @@ public Task>> ParseAsync( if (string.IsNullOrEmpty(catalogContent)) { - _logger.LogWarning("Catalog content is empty"); + logger.LogWarning("Catalog content is empty"); return Task.FromResult(OperationResult>.CreateSuccess(results)); } @@ -54,11 +52,11 @@ public Task>> ParseAsync( if (catalog.Items.Count == 0) { - _logger.LogWarning("No items found in catalog"); + logger.LogWarning("No items found in catalog"); return Task.FromResult(OperationResult>.CreateSuccess(results)); } - _logger.LogInformation( + logger.LogInformation( "Parsed {ItemCount} items from GenPatcher catalog (version {Version})", catalog.Items.Count, catalog.CatalogVersion); @@ -75,12 +73,12 @@ public Task>> ParseAsync( } } - _logger.LogInformation("Converted {Count} catalog items to search results", results.Count); + logger.LogInformation("Converted {Count} catalog items to search results", results.Count); return Task.FromResult(OperationResult>.CreateSuccess(results)); } catch (Exception ex) { - _logger.LogError(ex, "Failed to parse GenPatcher catalog"); + logger.LogError(ex, "Failed to parse GenPatcher catalog"); return Task.FromResult(OperationResult>.CreateFailure($"Failed to parse catalog: {ex.Message}")); } } @@ -184,7 +182,7 @@ private ParsedCatalog ParseDatContent(string content) if (versionMatch.Success) { catalog.CatalogVersion = versionMatch.Groups[1].Value; - _logger.LogDebug("dl.dat catalog version: {Version}", catalog.CatalogVersion); + logger.LogDebug("dl.dat catalog version: {Version}", catalog.CatalogVersion); continue; } @@ -192,7 +190,7 @@ private ParsedCatalog ParseDatContent(string content) var contentMatch = ContentLineRegex().Match(trimmedLine); if (!contentMatch.Success) { - _logger.LogDebug("Skipping unrecognized line: {Line}", trimmedLine.Length > 50 ? trimmedLine[..50] + "..." : trimmedLine); + logger.LogDebug("Skipping unrecognized line: {Line}", trimmedLine.Length > 50 ? trimmedLine[..50] + "..." : trimmedLine); continue; } @@ -203,7 +201,7 @@ private ParsedCatalog ParseDatContent(string content) if (!long.TryParse(sizeStr, out var fileSize)) { - _logger.LogWarning("Failed to parse file size '{Size}' for content code {Code}", sizeStr, code); + logger.LogWarning("Failed to parse file size '{Size}' for content code {Code}", sizeStr, code); continue; } @@ -228,7 +226,7 @@ private ParsedCatalog ParseDatContent(string content) catalog.Items = [.. contentByCode.Values]; - _logger.LogDebug( + logger.LogDebug( "Parsed {ItemCount} content items with {TotalMirrors} total mirrors", catalog.Items.Count, catalog.Items.Sum(i => i.Mirrors.Count)); @@ -255,7 +253,7 @@ private ParsedCatalog ParseDatContent(string content) if ((metadata.ContentType == ContentType.Patch && metadata.Category != GenPatcherContentCategory.OfficialPatch) || metadata.ContentType == ContentType.UnknownContentType) { - _logger.LogDebug("Filtering out content {Code} - restricted content type {Type}", item.ContentCode, metadata.ContentType); + logger.LogDebug("Filtering out content {Code} - restricted content type {Type}", item.ContentCode, metadata.ContentType); return null; } @@ -263,7 +261,7 @@ private ParsedCatalog ParseDatContent(string content) // and showing them in the UI only confuses users if (metadata.IsBaseDependency) { - _logger.LogDebug("Skipping base dependency {Code} ({Name}) - auto-installed as dependency", item.ContentCode, metadata.DisplayName); + logger.LogDebug("Skipping base dependency {Code} ({Name}) - auto-installed as dependency", item.ContentCode, metadata.DisplayName); return null; } @@ -271,7 +269,7 @@ private ParsedCatalog ParseDatContent(string content) // These clutter the UI, but English is often desired as a standalone patch. if (metadata.Category == GenPatcherContentCategory.OfficialPatch && metadata.LanguageCode != "en") { - _logger.LogDebug("Skipping official patch {Code} ({Language}) - not shown in UI", item.ContentCode, metadata.LanguageCode); + logger.LogDebug("Skipping official patch {Code} ({Language}) - not shown in UI", item.ContentCode, metadata.LanguageCode); return null; } @@ -279,7 +277,7 @@ private ParsedCatalog ParseDatContent(string content) var preferredUrl = GetPreferredDownloadUrl(item, provider); if (string.IsNullOrEmpty(preferredUrl)) { - _logger.LogWarning("No download URLs available for content code {Code}", item.ContentCode); + logger.LogWarning("No download URLs available for content code {Code}", item.ContentCode); return null; } @@ -336,7 +334,7 @@ private ParsedCatalog ParseDatContent(string content) result.ResolverMetadata[CommunityOutpostCatalogConstants.MirrorUrlsKey] = JsonSerializer.Serialize(absoluteUrls); result.ResolverMetadata[CommunityOutpostCatalogConstants.MirrorsKey] = string.Join(", ", item.Mirrors.Select(m => m.Name)); - _logger.LogDebug( + logger.LogDebug( "Created ContentSearchResult for {Code}: {Name} ({ContentType}, {Game})", item.ContentCode, metadata.DisplayName, @@ -347,7 +345,7 @@ private ParsedCatalog ParseDatContent(string content) } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to convert content item {Code} to search result", item.ContentCode); + logger.LogWarning(ex, "Failed to convert content item {Code} to search result", item.ContentCode); return null; } } diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index d7eba6242..fdd046c1d 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -1345,6 +1345,7 @@ private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecuta { try { + launcher.Refresh(); if (!launcher.HasExited) { return (false, null); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs index 145e0e2b9..039c3f824 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs @@ -423,10 +423,16 @@ private async Task RefreshSingleProfileAsync(string profileId) if (existingItem != null) { - // Use UpdateFromProfile to refresh the existing ViewModel in-place - // This preserves running state and just updates displayed properties (especially GameVersion) existingItem.UpdateFromProfile(profile); + var gameType = profile.GameClient?.GameType.ToString() ?? "ZeroHour"; + existingItem.IconPath = !string.IsNullOrEmpty(profile.IconPath) + ? profile.IconPath + : UriConstants.DefaultIconUri; + existingItem.CoverPath = !string.IsNullOrEmpty(profile.CoverPath) + ? profile.CoverPath + : profileResourceService.GetDefaultCoverPath(gameType); + logger.LogInformation("Refreshed profile {ProfileId} in-place (Running: {IsRunning})", profileId, existingItem.IsProcessRunning); } } @@ -697,13 +703,12 @@ private void ExpandHeader() [RelayCommand] private void StartHeaderTimer() { - _isHovering = false; - if (IsScanning) { return; // Don't collapse header while scanning } + _isHovering = false; _headerCollapseTimer.Stop(); _headerExpansionTimer.Stop(); // Cancel any pending expansion diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index cc85bccab..76d7f4ed8 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -167,22 +167,30 @@ public async Task> LoadTheSuperHackersS { using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { - var optionsResult = await LoadOptionsAsync(gameType); - if (!optionsResult.Success || optionsResult.Data == null) + try { - return OperationResult.CreateFailure(optionsResult.Errors); - } + var optionsResult = await LoadOptionsAsync(gameType); + if (!optionsResult.Success || optionsResult.Data == null) + { + return OperationResult.CreateFailure(optionsResult.Errors); + } - var settings = new TheSuperHackersSettings(); - var options = optionsResult.Data; + var settings = new TheSuperHackersSettings(); + var options = optionsResult.Data; - if (options.AdditionalSections.TryGetValue("TheSuperHackers", out var tshSection)) + if (options.AdditionalSections.TryGetValue("TheSuperHackers", out var tshSection)) + { + ParseTheSuperHackersSection(settings, tshSection); + } + + _logger.LogInformation("Loaded TheSuperHackers settings for {GameType}", gameType); + return OperationResult.CreateSuccess(settings); + } + catch (Exception ex) { - ParseTheSuperHackersSection(settings, tshSection); + _logger.LogError(ex, "Failed to load TheSuperHackers settings for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to load TheSuperHackers settings: {ex.Message}"); } - - _logger.LogInformation("Loaded TheSuperHackers settings for {GameType}", gameType); - return OperationResult.CreateSuccess(settings); } } @@ -191,18 +199,37 @@ public async Task> SaveTheSuperHackersSettingsAsync(GameTy { using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { - var optionsResult = await LoadOptionsAsync(gameType); - if (!optionsResult.Success || optionsResult.Data == null) + try { - return OperationResult.CreateFailure(optionsResult.Errors); - } + var optionsResult = await LoadOptionsAsync(gameType); + if (!optionsResult.Success || optionsResult.Data == null) + { + return OperationResult.CreateFailure(optionsResult.Errors); + } - var options = optionsResult.Data; - var tshSection = SerializeTheSuperHackersSettings(settings); - options.AdditionalSections["TheSuperHackers"] = tshSection; + var options = optionsResult.Data; + Dictionary tshSection = []; + if (options.AdditionalSections.TryGetValue("TheSuperHackers", out var existingTsh) && existingTsh != null) + { + tshSection = new Dictionary(existingTsh, StringComparer.OrdinalIgnoreCase); + } - var saveResult = await SaveOptionsAsync(gameType, options); - return saveResult; + var serializedTsh = SerializeTheSuperHackersSettings(settings); + foreach (var kvp in serializedTsh) + { + tshSection[kvp.Key] = kvp.Value; + } + + options.AdditionalSections["TheSuperHackers"] = tshSection; + + var saveResult = await SaveOptionsAsync(gameType, options); + return saveResult; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save TheSuperHackers settings for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to save TheSuperHackers settings: {ex.Message}"); + } } } diff --git a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs index 8d269e1c2..7ae9ac3c7 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs @@ -7,8 +7,10 @@ using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Messages; using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.ViewModels; @@ -22,7 +24,7 @@ namespace GenHub.Features.Tools.ViewModels; /// The tool service for managing plugins. /// The logger instance. /// The service provider for dependency injection. -public partial class ToolsViewModel(IToolManager toolService, ILogger logger, IServiceProvider serviceProvider) : ObservableObject +public partial class ToolsViewModel(IToolManager toolService, ILogger logger, IServiceProvider serviceProvider) : ObservableObject, IRecipient { [ObservableProperty] private IToolPlugin? _selectedTool; @@ -70,6 +72,15 @@ public partial class ToolsViewModel(IToolManager toolService, ILogger public ObservableCollection InstalledTools { get; } = []; + /// + /// Receives tool status messages. + /// + /// The tool status message. + public void Receive(ToolStatusMessage message) + { + ShowStatusMessage(message.Message, message.Type); + } + /// /// Initializes the ViewModel by loading saved tools. /// @@ -78,6 +89,11 @@ public async Task InitializeAsync() { try { + if (!WeakReferenceMessenger.Default.IsRegistered(this)) + { + WeakReferenceMessenger.Default.Register(this); + } + IsLoading = true; var result = await toolService.LoadSavedToolsAsync(); @@ -94,6 +110,7 @@ public async Task InitializeAsync() if (HasTools) { + // Select the first tool by default SelectedTool = InstalledTools[0]; } @@ -101,14 +118,14 @@ public async Task InitializeAsync() } else { - ShowStatusMessage($"⚠ Failed to load tools: {string.Join(", ", result.Errors)}", error: true); + ShowStatusMessage($"⚠ Failed to load tools: {string.Join(", ", result.Errors)}", MessageType.Error); logger.LogWarning("Failed to load tools: {Errors}", string.Join(", ", result.Errors)); } } catch (Exception ex) { - ShowStatusMessage($"⚠ An error occurred while loading tools: {ex.Message}", error: true); - logger.LogError(ex, "Error initializing ToolsViewModel"); + ShowStatusMessage($"⚠ An error occurred while loading tools: {ex.Message}", MessageType.Error); + logger.LogError(ex, "Error loading tools"); } finally { @@ -173,9 +190,7 @@ private async Task AddToolAsync() { var assemblyPath = files[0].Path.LocalPath; IsLoading = true; - StatusMessage = "Installing tool..."; - SetStatusType(info: true); - IsStatusVisible = true; + ShowStatusMessage("Installing tool...", MessageType.Info); var result = await toolService.AddToolAsync(assemblyPath); @@ -186,12 +201,12 @@ private async Task AddToolAsync() SelectedTool = result.Data; var versionDisplay = string.IsNullOrEmpty(result.Data.Metadata.Version) ? string.Empty : $" v{result.Data.Metadata.Version}"; - ShowStatusMessage($"✓ Tool '{result.Data.Metadata.Name}'{versionDisplay} installed successfully.", success: true); + ShowStatusMessage($"✓ Tool '{result.Data.Metadata.Name}'{versionDisplay} installed successfully.", MessageType.Success); logger.LogInformation("Tool {ToolName} added successfully", result.Data.Metadata.Name); } else { - ShowStatusMessage($"✗ Failed to install tool: {string.Join(", ", result.Errors)}", error: true); + ShowStatusMessage($"✗ Failed to install tool: {string.Join(", ", result.Errors)}", MessageType.Error); logger.LogWarning("Failed to add tool: {Errors}", string.Join(", ", result.Errors)); } @@ -201,7 +216,7 @@ private async Task AddToolAsync() catch (Exception ex) { IsLoading = false; - ShowStatusMessage($"✗ An error occurred while adding the tool: {ex.Message}", error: true); + ShowStatusMessage($"✗ An error occurred while adding the tool: {ex.Message}", MessageType.Error); logger.LogError(ex, "Error adding tool"); } } @@ -216,16 +231,14 @@ private async Task RemoveToolAsync(IToolPlugin? tool = null) if (toolToRemove == null) return; if (toolToRemove.Metadata.IsBundled) { - ShowStatusMessage($"✗ Tool '{toolToRemove.Metadata.Name}' is a bundled tool and cannot be removed.", error: true); + ShowStatusMessage($"✗ Tool '{toolToRemove.Metadata.Name}' is a bundled tool and cannot be removed.", MessageType.Error); return; } try { IsLoading = true; - StatusMessage = $"Removing tool '{toolToRemove.Metadata.Name}'..."; - SetStatusType(info: true); - IsStatusVisible = true; + ShowStatusMessage($"Removing tool '{toolToRemove.Metadata.Name}'...", MessageType.Info); // Deactivate the tool before removal toolToRemove.OnDeactivated(); @@ -252,13 +265,13 @@ private async Task RemoveToolAsync(IToolPlugin? tool = null) SelectedTool = InstalledTools.FirstOrDefault(); } - ShowStatusMessage($"✓ Tool '{toolToRemove.Metadata.Name}' removed successfully.", success: true); + ShowStatusMessage($"✓ Tool '{toolToRemove.Metadata.Name}' removed successfully.", MessageType.Success); logger.LogInformation("Tool {ToolId} removed successfully", toolToRemove.Metadata.Id); } else { - ShowStatusMessage($"✗ Failed to remove tool: {string.Join(", ", result.Errors)}", error: true); + ShowStatusMessage($"✗ Failed to remove tool: {string.Join(", ", result.Errors)}", MessageType.Error); logger.LogWarning("Failed to remove tool: {Errors}", string.Join(", ", result.Errors)); } @@ -267,7 +280,7 @@ private async Task RemoveToolAsync(IToolPlugin? tool = null) catch (Exception ex) { IsLoading = false; - ShowStatusMessage($"✗ An error occurred while removing the tool: {ex.Message}", error: true); + ShowStatusMessage($"✗ An error occurred while removing the tool: {ex.Message}", MessageType.Error); logger.LogError(ex, "Error removing tool"); } } @@ -281,9 +294,7 @@ private async Task RefreshToolsAsync() try { IsLoading = true; - StatusMessage = "Refreshing tools..."; - SetStatusType(info: true); - IsStatusVisible = true; + ShowStatusMessage("Refreshing tools...", MessageType.Info); // Store the current selection var previousSelectedId = SelectedTool?.Metadata.Id; @@ -322,24 +333,24 @@ private async Task RefreshToolsAsync() ?? InstalledTools[0]; SelectedTool = toolToSelect; - ShowStatusMessage($"✓ Refreshed {InstalledTools.Count} tool(s) successfully.", success: true); + ShowStatusMessage($"✓ Refreshed {InstalledTools.Count} tool(s) successfully.", MessageType.Success); } else { - ShowStatusMessage("✓ Refreshed tools list.", success: true); + ShowStatusMessage("✓ Refreshed tools list.", MessageType.Success); } logger.LogInformation("Refreshed {Count} tool plugins", InstalledTools.Count); } else { - ShowStatusMessage($"⚠ Failed to refresh tools: {string.Join(", ", result.Errors)}", error: true); + ShowStatusMessage($"⚠ Failed to refresh tools: {string.Join(", ", result.Errors)}", MessageType.Error); logger.LogWarning("Failed to refresh tools: {Errors}", string.Join(", ", result.Errors)); } } catch (Exception ex) { - ShowStatusMessage($"⚠ An error occurred while refreshing tools: {ex.Message}", error: true); + ShowStatusMessage($"⚠ An error occurred while refreshing tools: {ex.Message}", MessageType.Error); logger.LogError(ex, "Error refreshing tools"); } finally @@ -377,7 +388,7 @@ partial void OnSelectedToolChanged(IToolPlugin? oldValue, IToolPlugin? newValue) { logger.LogError(ex, "Error activating tool: {ToolName}", newValue.Metadata.Name); CurrentToolControl = null; - ShowStatusMessage($"✗ Error loading tool '{newValue.Metadata.Name}': {ex.Message}", error: true); + ShowStatusMessage($"✗ Error loading tool '{newValue.Metadata.Name}': {ex.Message}", MessageType.Error); } } else @@ -386,13 +397,6 @@ partial void OnSelectedToolChanged(IToolPlugin? oldValue, IToolPlugin? newValue) } } - private void SetStatusType(bool success = false, bool error = false, bool info = false) - { - IsStatusSuccess = success; - IsStatusError = error; - IsStatusInfo = info; - } - /// /// Shows the details dialog for a specific tool. /// @@ -416,18 +420,20 @@ private void CloseDetailsDialog() ToolForDetails = null; } - private void ShowStatusMessage(string message, bool success = false, bool error = false, bool info = false) + private void ShowStatusMessage(string message, MessageType type = MessageType.Info) { // Cancel any existing hide timer _statusHideCts?.Cancel(); _statusHideCts?.Dispose(); StatusMessage = message; - SetStatusType(success, error, info); + IsStatusSuccess = type == MessageType.Success; + IsStatusError = type == MessageType.Error || type == MessageType.Warning; + IsStatusInfo = type == MessageType.Info; IsStatusVisible = true; var cts = new System.Threading.CancellationTokenSource(); _statusHideCts = cts; _ = AutoHideStatusAsync(() => IsStatusVisible = false, cts.Token); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml index 033583eb8..f1e7f699a 100644 --- a/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml +++ b/GenHub/GenHub/Features/Tools/Views/ToolsView.axaml @@ -176,49 +176,45 @@ - + + + + + - - - - - - - - + + + + + + - - - - - - - - - - - - - - - + + + + - - + - - + + - - + + - - + + - - + +