diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index 1be55ad77..09d58be56 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -30,6 +30,7 @@ + diff --git a/GenHub/GenHub.Core/Constants/ActionSetConstants.cs b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs new file mode 100644 index 000000000..a7c8a9517 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs @@ -0,0 +1,195 @@ +namespace GenHub.Core.Constants; + +/// +/// 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 + } + + /// + /// 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"; + } + + /// + /// 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 = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + /// + /// Gets the DisplayName value name in the registry. + /// + public const string DisplayNameValue = "DisplayName"; + + /// + /// 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 string[] 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"; + } + + /// + /// Validation constants for file operations. + /// + public static class Validation + { + /// + /// Minimum file size for VCRedist installers (1000 KB). + /// + public const long VCRedistMinSize = 1000 * 1024; + } +} diff --git a/GenHub/GenHub.Core/Constants/ExternalUrls.cs b/GenHub/GenHub.Core/Constants/ExternalUrls.cs new file mode 100644 index 000000000..d74ee37de --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ExternalUrls.cs @@ -0,0 +1,85 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for external URLs used for downloading dependencies or tools. +/// +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 = "http://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 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"; +} diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index d2f59ea90..e27631483 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -127,6 +127,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 e689b8b2a..f1575d7f0 100644 --- a/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs @@ -178,4 +178,173 @@ 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 Gamma = 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"; + } } diff --git a/GenHub/GenHub.Core/Constants/RegistryConstants.cs b/GenHub/GenHub.Core/Constants/RegistryConstants.cs new file mode 100644 index 000000000..153dc434a --- /dev/null +++ b/GenHub/GenHub.Core/Constants/RegistryConstants.cs @@ -0,0 +1,110 @@ +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 ===== + + /// 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"; + + /// Registry value name for TFD Version. + public const string TfdVersionValue = "1.03"; + + // ===== 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 value name for Origin Client Path. + public const string OriginClientPathValue = "ClientPath"; +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs new file mode 100644 index 000000000..7b2c5ae24 --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs @@ -0,0 +1,158 @@ +namespace GenHub.Core.Features.ActionSets; + +using System; +using System.Collections.Generic; +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. +/// +public class ActionSetOrchestrator : IActionSetOrchestrator +{ + private readonly IEnumerable _actionSets; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The initial collection of action sets. + /// The collection of action set providers. + /// The logger instance. + public ActionSetOrchestrator( + IEnumerable actionSets, + IEnumerable providers, + ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var allSets = new List(actionSets ?? []); + + if (providers != null) + { + foreach (var provider in providers) + { + try + { + allSets.AddRange(provider.GetActionSets()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load action sets from provider {Provider}", provider.GetType().Name); + } + } + } + + _actionSets = allSets; + } + + /// + public IEnumerable GetAllActionSets() => _actionSets; + + /// + public async Task> GetApplicableCoreFixesAsync(GameInstallation installation) + { + var applicable = new List(); + foreach (var actionSet in _actionSets.Where(x => x.IsCoreFix)) + { + if (await actionSet.IsApplicableAsync(installation)) + { + applicable.Add(actionSet); + } + } + + return applicable; + } + + /// + public async Task> ApplyActionSetsAsync( + GameInstallation installation, + IEnumerable actionSets, + CancellationToken ct = default) + { + 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++) + { + var actionSet = actionSetsList[i]; + if (ct.IsCancellationRequested) + { + _logger.LogWarning("Action set application cancelled by user"); + break; + } + + // Double check applicability and applied state to avoid redundant work + if (!await actionSet.IsApplicableAsync(installation)) + { + _logger.LogDebug("Skipping {Title} - not applicable", actionSet.Title); + continue; + } + + if (await actionSet.IsAppliedAsync(installation)) + { + _logger.LogDebug("Skipping {Title} - already applied", actionSet.Title); + continue; + } + + _logger.LogInformation("Applying fix {Current}/{Total}: {Title}", i + 1, totalCount, actionSet.Title); + + var result = await actionSet.ApplyAsync(installation, ct); + if (result.Success) + { + successCount++; + _logger.LogInformation("✓ Successfully applied {Title} ({Current}/{Total})", actionSet.Title, i + 1, totalCount); + + if (result.Details?.Count > 0) + { + foreach (var detail in result.Details) + { + _logger.LogDebug(" {Detail}", detail); + } + } + } + else + { + var errorMsg = $"Failed to apply {actionSet.Title}: {result.ErrorMessage}"; + errors.Add(errorMsg); + _logger.LogWarning("✗ {ErrorMsg}", errorMsg); + + if (result.Details?.Count > 0) + { + foreach (var detail in result.Details) + { + _logger.LogDebug(" {Detail}", detail); + } + } + + if (actionSet.IsCrucialFix) + { + _logger.LogError("Critical fix {Title} failed for {Installation}. Aborting sequence.", actionSet.Title, installation.InstallationPath); + errors.Add($"Critical fix '{actionSet.Title}' failed. Remaining fixes were not applied."); + return OperationResult.CreateFailure(errors, successCount); + } + } + } + + _logger.LogInformation( + "Action set application completed: {SuccessCount}/{TotalCount} successful, {ErrorCount} errors", + successCount, + totalCount, + errors.Count); + + if (errors.Count > 0) + { + return OperationResult.CreateFailure(errors, successCount); + } + + return OperationResult.CreateSuccess(successCount); + } +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs new file mode 100644 index 000000000..8f90b1e31 --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs @@ -0,0 +1,121 @@ +namespace GenHub.Core.Features.ActionSets; + +using System; +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 : IActionSet +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + protected BaseActionSet(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public abstract string Id { get; } + + /// + public abstract string Title { get; } + + /// + public abstract bool IsCoreFix { get; } + + /// + public abstract bool IsCrucialFix { get; } + + /// + public abstract Task IsApplicableAsync(GameInstallation installation); + + /// + public abstract Task IsAppliedAsync(GameInstallation installation); + + /// + 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 (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 (Exception ex) + { + _logger.LogError(ex, "Error undoing ActionSet {Title} ({Id})", Title, Id); + return new ActionSetResult(false, ex.Message); + } + } + + /// + /// 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); + + /// + /// Helper to return a successful result. + /// + /// A successful ActionSetResult. + protected ActionSetResult Success() => new(true); + + /// + /// Helper to return a failed result. + /// + /// The error message. + /// A failed ActionSetResult. + protected ActionSetResult Failure(string message) => new(false, message); +} diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs new file mode 100644 index 000000000..d37d6fc73 --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs @@ -0,0 +1,112 @@ +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 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. + /// A task representing the asynchronous operation, returning true if applicable. + Task IsApplicableAsync(GameInstallation installation); + + /// + /// Checks if the action set has already been applied. + /// + /// The game installation to check. + /// A task representing the asynchronous operation, returning true if applied. + Task IsAppliedAsync(GameInstallation installation); + + /// + /// 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. +/// +/// Whether the operation succeeded. +/// Error message if the operation failed. +/// Detailed list of actions taken during the operation. +public record ActionSetResult(bool Success, string? ErrorMessage = null, List? Details = null) +{ + /// + /// Gets the details list, creating one if needed. + /// + public List Details { get; init; } = Details ?? []; + + /// + /// 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 this with { Details = 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..51de34264 --- /dev/null +++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs @@ -0,0 +1,35 @@ +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. + IEnumerable GetAllActionSets(); + + /// + /// Gets applicable core fixes for a given installation. + /// + /// The game installation. + /// A task returning the list of applicable core fixes. + Task> GetApplicableCoreFixesAsync(GameInstallation installation); + + /// + /// 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/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..50dc9298a 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 = null); /// /// 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 af2a42d42..b4cfdf90e 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -73,8 +73,8 @@ public static class GenPatcherContentRegistry ["cbbs"] = new GenPatcherContentMetadata { ContentCode = "cbbs", - DisplayName = "Control Bar - Basic", - Description = "Basic control bar addon", + DisplayName = "Control Bar HD (Base)", + Description = "Base files for Control Bar HD", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.ControlBar, @@ -83,8 +83,8 @@ public static class GenPatcherContentRegistry ["cben"] = new GenPatcherContentMetadata { ContentCode = "cben", - DisplayName = "Control Bar - Enhanced", - Description = "Enhanced control bar with additional features", + DisplayName = "Control Bar HD (English)", + Description = "English language pack for Control Bar HD", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.ControlBar, @@ -93,8 +93,8 @@ public static class GenPatcherContentRegistry ["cbpc"] = new GenPatcherContentMetadata { ContentCode = "cbpc", - DisplayName = "Control Bar - PC Style", - Description = "PC-style control bar layout", + DisplayName = "Control Bar Pro (Common)", + Description = "Common files for Control Bar Pro (ExiLe/Xezon)", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.ControlBar, @@ -103,8 +103,8 @@ public static class GenPatcherContentRegistry ["cbpr"] = new GenPatcherContentMetadata { ContentCode = "cbpr", - DisplayName = "Control Bar - Pro", - Description = "Professional control bar addon", + DisplayName = "Control Bar Pro (ExiLe)", + Description = "Base files for Control Bar Pro (ExiLe)", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.ControlBar, @@ -113,8 +113,8 @@ public static class GenPatcherContentRegistry ["cbpx"] = new GenPatcherContentMetadata { ContentCode = "cbpx", - DisplayName = "Control Bar - Extended", - Description = "Extended control bar with extra functionality", + DisplayName = "Control Bar Pro (Xezon)", + Description = "Base files for Control Bar Pro (Xezon)", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.ControlBar, @@ -153,32 +153,32 @@ public static class GenPatcherContentRegistry InstallTarget = ContentInstallTarget.Workspace, }, - // Hotkeys + // Hotkeys & WorldBuilder ["ewba"] = new GenPatcherContentMetadata { ContentCode = "ewba", - DisplayName = "Easy Win Hotkeys - Advanced", - Description = "Advanced hotkey configuration", + DisplayName = "Enhanced World Builder v2.7 by Adriane", + Description = "Advanced World Builder with new theme", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, }, ["ewbi"] = new GenPatcherContentMetadata { ContentCode = "ewbi", - DisplayName = "Easy Win Hotkeys - International", - Description = "International hotkey layout", + DisplayName = "Enhanced World Builder v2.2", + Description = "Standard Enhanced World Builder", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, }, ["hlde"] = new GenPatcherContentMetadata { ContentCode = "hlde", - DisplayName = "Hotkeys - German", - Description = "German hotkey configuration", + DisplayName = "Leikeze's Hotkeys Indicators (German)", + Description = "German version of Leikeze's indicators", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, LanguageCode = "de", @@ -188,8 +188,8 @@ public static class GenPatcherContentRegistry ["hleg"] = new GenPatcherContentMetadata { ContentCode = "hleg", - DisplayName = "Hotkeys - English (Grid)", - Description = "English grid-based hotkey layout", + DisplayName = "Legionnaire's Hotkeys", + Description = "Legionnaire's custom hotkey layout", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, LanguageCode = "en", @@ -199,8 +199,8 @@ public static class GenPatcherContentRegistry ["hlei"] = new GenPatcherContentMetadata { ContentCode = "hlei", - DisplayName = "Hotkeys - English (Icons)", - Description = "English icon-based hotkey layout", + DisplayName = "Leikeze's Hotkeys (Icons)", + Description = "Leikeze's hotkeys with icon support", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, LanguageCode = "en", @@ -210,8 +210,8 @@ public static class GenPatcherContentRegistry ["hlen"] = new GenPatcherContentMetadata { ContentCode = "hlen", - DisplayName = "Hotkeys - English", - Description = "Standard English hotkey configuration", + DisplayName = "Leikeze's Hotkeys Indicators", + Description = "Leikeze's hotkey indicators overlay", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, LanguageCode = "en", @@ -230,43 +230,65 @@ public static class GenPatcherContentRegistry Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, }, + ["gena"] = new GenPatcherContentMetadata + { + ContentCode = "gena", + DisplayName = "GenAssist", + Description = "GenAssist helper utility", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Category = GenPatcherContentCategory.Tools, + InstallTarget = ContentInstallTarget.Workspace, + }, ["genl"] = new GenPatcherContentMetadata { ContentCode = "genl", DisplayName = "GenLauncher", - Description = "Alternative launcher for Generals/Zero Hour", + Description = "GenLauncher standalone launcher utility", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, }, - ["gena"] = new GenPatcherContentMetadata + + // Prerequisites - System dependencies (VCRedist, DirectX, etc.) + ["vc05"] = new GenPatcherContentMetadata { - ContentCode = "gena", - DisplayName = "GenAssist", - Description = "GenAssist helper utility", + ContentCode = "vc05", + DisplayName = "Visual C++ 2005 Redistributable", + Description = "Microsoft Visual C++ 2005 Redistributable Package", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Tools, - InstallTarget = ContentInstallTarget.Workspace, + Category = GenPatcherContentCategory.Prerequisites, + InstallTarget = ContentInstallTarget.System, }, - ["laun"] = new GenPatcherContentMetadata + ["vc08"] = new GenPatcherContentMetadata { - ContentCode = "laun", - DisplayName = "Launcher", - Description = "Game launcher component", + ContentCode = "vc08", + DisplayName = "Visual C++ 2008 Redistributable", + Description = "Microsoft Visual C++ 2008 Redistributable Package", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Tools, - InstallTarget = ContentInstallTarget.Workspace, + Category = GenPatcherContentCategory.Prerequisites, + InstallTarget = ContentInstallTarget.System, + }, + ["vc10"] = new GenPatcherContentMetadata + { + ContentCode = "vc10", + DisplayName = "Visual C++ 2010 Redistributable", + Description = "Microsoft Visual C++ 2010 Redistributable Package", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + Category = GenPatcherContentCategory.Prerequisites, + InstallTarget = ContentInstallTarget.System, }, // Maps and Missions - These go to user Documents directory ["maod"] = new GenPatcherContentMetadata { ContentCode = "maod", - DisplayName = "Map Addon", - Description = "Additional maps addon pack", + DisplayName = "AOD and comp-stomp maps", + Description = "Maps designed for AOD/Comp-Stomp", ContentType = ContentType.MapPack, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.Maps, @@ -275,8 +297,8 @@ public static class GenPatcherContentRegistry ["mmis"] = new GenPatcherContentMetadata { ContentCode = "mmis", - DisplayName = "Missions Pack", - Description = "Custom missions pack", + DisplayName = "Single player and multiplayer co-op missions", + Description = "Custom missions campaign", ContentType = ContentType.Mission, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.Maps, @@ -285,8 +307,8 @@ public static class GenPatcherContentRegistry ["mscr"] = new GenPatcherContentMetadata { ContentCode = "mscr", - DisplayName = "Map Scripts", - Description = "Map scripting resources", + DisplayName = "Scripted and no-money maps", + Description = "Maps with heavy scripting or no-money rules", ContentType = ContentType.MapPack, TargetGame = GameType.ZeroHour, Category = GenPatcherContentCategory.Maps, @@ -295,8 +317,8 @@ public static class GenPatcherContentRegistry ["mskr"] = new GenPatcherContentMetadata { ContentCode = "mskr", - DisplayName = "Map Pack - Korean", - Description = "Korean map pack", + DisplayName = "Skirmish and multiplayer maps", + Description = "Collection of skirmish and MP maps", ContentType = ContentType.MapPack, TargetGame = GameType.ZeroHour, LanguageCode = "ko", @@ -335,38 +357,6 @@ public static class GenPatcherContentRegistry Category = GenPatcherContentCategory.Visuals, InstallTarget = ContentInstallTarget.Workspace, }, - - // Prerequisites - System install - ["vc05"] = new GenPatcherContentMetadata - { - ContentCode = "vc05", - DisplayName = "VC++ 2005 Redistributable", - Description = "Microsoft Visual C++ 2005 Redistributable (x86)", - ContentType = ContentType.Addon, - TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Prerequisites, - InstallTarget = ContentInstallTarget.System, - }, - ["vc08"] = new GenPatcherContentMetadata - { - ContentCode = "vc08", - DisplayName = "VC++ 2008 Redistributable", - Description = "Microsoft Visual C++ 2008 Redistributable (x86)", - ContentType = ContentType.Addon, - TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Prerequisites, - InstallTarget = ContentInstallTarget.System, - }, - ["vc10"] = new GenPatcherContentMetadata - { - ContentCode = "vc10", - DisplayName = "VC++ 2010 Redistributable", - Description = "Microsoft Visual C++ 2010 Redistributable (x86)", - ContentType = ContentType.Addon, - TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Prerequisites, - InstallTarget = ContentInstallTarget.System, - }, }; /// @@ -451,7 +441,6 @@ public static bool IsKnownCode(string contentCode) // Determine target game based on version // 108 = Generals 1.08, 104 = Zero Hour 1.04 var isGenerals = versionNumber == 8; // 1.08 is Generals - var isZeroHour = versionNumber == 4; // 1.04 is Zero Hour var targetGame = isGenerals ? GameType.Generals : GameType.ZeroHour; var version = $"1.0{versionNumber}"; 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 85fc9d236..8bbf3b61e 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; @@ -10,7 +12,7 @@ public record NotificationMessage /// /// Gets the unique identifier for this notification. /// - public Guid Id { get; init; } + public Guid Id { get; init; } = Guid.NewGuid(); /// /// Gets the type of notification. @@ -30,7 +32,7 @@ public record NotificationMessage /// /// Gets the timestamp when the notification was created. /// - public DateTime Timestamp { get; init; } + public DateTime Timestamp { get; init; } = DateTime.UtcNow; /// /// Gets the auto-dismiss timeout in milliseconds. Null means no auto-dismiss. @@ -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/Models/Results/OperationResultOfT.cs b/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs index e936cb58d..f9be2fd32 100644 --- a/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs +++ b/GenHub/GenHub.Core/Models/Results/OperationResultOfT.cs @@ -53,11 +53,21 @@ public static OperationResult CreateFailure(string error, TimeSpan elapsed = /// The elapsed time. /// A failed . public static OperationResult CreateFailure(IEnumerable errors, TimeSpan elapsed = default) + { + return CreateFailure(errors, default, elapsed); + } + + /// Creates a failed operation result with multiple error messages and partial data. + /// The error messages. + /// The partial data. + /// The elapsed time. + /// A failed . + public static OperationResult CreateFailure(IEnumerable errors, T? data, TimeSpan elapsed = default) { ArgumentNullException.ThrowIfNull(errors, nameof(errors)); if (!errors.Any()) throw new ArgumentException("Errors collection cannot be empty.", nameof(errors)); - return new OperationResult(false, default, errors, elapsed); + return new OperationResult(false, data, errors, elapsed); } /// Creates a failed operation result from another result, copying its errors. diff --git a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs index 180c828bc..bbf437312 100644 --- a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs +++ b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs @@ -32,10 +32,13 @@ public IReadOnlyList GetAllTools() } /// - public void RegisterTool(IToolPlugin plugin, string assemblyPath) + public void RegisterTool(IToolPlugin plugin, string? assemblyPath = null) { _tools[plugin.Metadata.Id] = plugin; - _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath; + if (assemblyPath != null) + { + _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath; + } } /// 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.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs index 477a3fd86..9a1380299 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/IsSubscribedConverterTests.cs @@ -124,7 +124,7 @@ public void Convert_ReturnsFalse_WhenValuesCountTooLow() public void ConvertBack_ReturnsEmptyArray() { // Act - var result = IsSubscribedConverter.ConvertBack(true, [typeof(object), typeof(object), typeof(object)], null, CultureInfo.InvariantCulture); + var result = _converter.ConvertBack(true, [typeof(object), typeof(object), typeof(object)], null, CultureInfo.InvariantCulture); // Assert Assert.Empty(result); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs index ea7e275bd..f097f633f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs @@ -23,7 +23,7 @@ public class GenPatcherContentRegistryTests [InlineData("genl", "GenLauncher", ContentType.Addon, GameType.ZeroHour)] [InlineData("10gn", "Generals 1.08", ContentType.GameClient, GameType.Generals)] [InlineData("10zh", "Zero Hour 1.04", ContentType.GameClient, GameType.ZeroHour)] - [InlineData("cbbs", "Control Bar - Basic", ContentType.Addon, GameType.ZeroHour)] + [InlineData("cbbs", "Control Bar HD (Base)", ContentType.Addon, GameType.ZeroHour)] [InlineData("crzh", "Camera Mod - Zero Hour", ContentType.Addon, GameType.ZeroHour)] public void GetMetadata_ReturnsCorrectMetadataForKnownCodes( string contentCode, @@ -183,6 +183,8 @@ public void GetKnownContentCodes_ReturnsNonEmptyCollection() [InlineData("cbbs", GenPatcherContentCategory.ControlBar)] [InlineData("crzh", GenPatcherContentCategory.Camera)] [InlineData("hlen", GenPatcherContentCategory.Hotkeys)] + [InlineData("ewba", GenPatcherContentCategory.Tools)] + [InlineData("mskr", GenPatcherContentCategory.Maps)] [InlineData("gent", GenPatcherContentCategory.Tools)] [InlineData("maod", GenPatcherContentCategory.Maps)] [InlineData("icon", GenPatcherContentCategory.Visuals)] 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 3df3cf8c6..05391f780 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs @@ -52,10 +52,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/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index 582a39975..ae40f8ce6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -1,12 +1,14 @@ using System.Reactive.Linq; using GenHub.Common.ViewModels; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.Providers; using GenHub.Core.Interfaces.Shortcuts; using GenHub.Core.Interfaces.Steam; using GenHub.Core.Interfaces.Storage; @@ -18,12 +20,14 @@ using GenHub.Core.Models.Notifications; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.Content.Services.ContentDiscoverers; +using GenHub.Features.Content.Services.Publishers; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GameProfiles.Services; using GenHub.Features.GameProfiles.ViewModels; using GenHub.Features.Notifications.ViewModels; using GenHub.Features.Settings.ViewModels; using GenHub.Features.Tools.ViewModels; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -53,21 +57,23 @@ public void Constructor_CreatesValidInstance() Mock.Of>(), Mock.Of>()); - var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); - // Act + var mockNotificationFeedVm = new Mock( + mockNotificationService.Object, + Mock.Of(), + Mock.Of>()); var vm = new MainViewModel( - gameProfilesViewModel: CreateGameProfileLauncherViewModel(), - downloadsViewModel: CreateDownloadsViewModel(), - toolsViewModel: toolsVm, - settingsViewModel: settingsVm, - notificationManager: mockNotificationManager.Object, - configurationProvider: configProvider, - userSettingsService: userSettingsMock.Object, - velopackUpdateManager: mockVelopackUpdateManager.Object, - notificationService: mockNotificationService.Object, - notificationFeedViewModel: notificationFeedVm, - logger: mockLogger.Object); + CreateGameProfileLauncherViewModel(), + CreateDownloadsViewModel(), + toolsVm, + settingsVm, + mockNotificationManager.Object, + configProvider, + userSettingsMock.Object, + mockVelopackUpdateManager.Object, + mockNotificationService.Object, + mockNotificationFeedVm.Object, + mockLogger.Object); // Assert Assert.NotNull(vm); @@ -95,20 +101,22 @@ public void SelectTabCommand_SetsSelectedTab(NavigationTab tab) mockNotificationService.Object, Mock.Of>(), Mock.Of>()); - var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); - + var mockNotificationFeedVm = new Mock( + mockNotificationService.Object, + Mock.Of(), + Mock.Of>()); var vm = new MainViewModel( - gameProfilesViewModel: CreateGameProfileLauncherViewModel(), - downloadsViewModel: CreateDownloadsViewModel(), - toolsViewModel: toolsVm, - settingsViewModel: settingsVm, - notificationManager: mockNotificationManager.Object, - configurationProvider: configProvider, - userSettingsService: userSettingsMock.Object, - velopackUpdateManager: mockVelopackUpdateManager.Object, - notificationService: mockNotificationService.Object, - notificationFeedViewModel: notificationFeedVm, - logger: mockLogger.Object); + CreateGameProfileLauncherViewModel(), + CreateDownloadsViewModel(), + toolsVm, + settingsVm, + mockNotificationManager.Object, + configProvider, + userSettingsMock.Object, + mockVelopackUpdateManager.Object, + mockNotificationService.Object, + mockNotificationFeedVm.Object, + mockLogger.Object); vm.SelectTabCommand.Execute(tab); Assert.Equal(tab, vm.SelectedTab); } @@ -133,20 +141,22 @@ public async Task InitializeAsync_MultipleCallsAreSafe() mockNotificationService.Object, Mock.Of>(), Mock.Of>()); - var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); - + var mockNotificationFeedVm = new Mock( + mockNotificationService.Object, + Mock.Of(), + Mock.Of>()); var vm = new MainViewModel( - gameProfilesViewModel: CreateGameProfileLauncherViewModel(), - downloadsViewModel: CreateDownloadsViewModel(), - toolsViewModel: toolsVm, - settingsViewModel: settingsVm, - notificationManager: mockNotificationManager.Object, - configurationProvider: configProvider, - userSettingsService: userSettingsMock.Object, - velopackUpdateManager: mockVelopackUpdateManager.Object, - notificationService: mockNotificationService.Object, - notificationFeedViewModel: notificationFeedVm, - logger: mockLogger.Object); + CreateGameProfileLauncherViewModel(), + CreateDownloadsViewModel(), + toolsVm, + settingsVm, + mockNotificationManager.Object, + configProvider, + userSettingsMock.Object, + mockVelopackUpdateManager.Object, + mockNotificationService.Object, + mockNotificationFeedVm.Object, + mockLogger.Object); await vm.InitializeAsync(); // Should not throw Assert.True(true); } @@ -172,20 +182,22 @@ public void CurrentTabViewModel_ReturnsCorrectViewModel(NavigationTab tab) mockNotificationService.Object, Mock.Of>(), Mock.Of>()); - var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); - + var mockNotificationFeedVm = new Mock( + mockNotificationService.Object, + Mock.Of(), + Mock.Of>()); var vm = new MainViewModel( - gameProfilesViewModel: CreateGameProfileLauncherViewModel(), - downloadsViewModel: CreateDownloadsViewModel(), - toolsViewModel: toolsVm, - settingsViewModel: settingsVm, - notificationManager: mockNotificationManager.Object, - configurationProvider: configProvider, - userSettingsService: userSettingsMock.Object, - velopackUpdateManager: mockVelopackUpdateManager.Object, - notificationService: mockNotificationService.Object, - notificationFeedViewModel: notificationFeedVm, - logger: mockLogger.Object); + CreateGameProfileLauncherViewModel(), + CreateDownloadsViewModel(), + toolsVm, + settingsVm, + mockNotificationManager.Object, + configProvider, + userSettingsMock.Object, + mockVelopackUpdateManager.Object, + mockNotificationService.Object, + mockNotificationFeedVm.Object, + mockLogger.Object); vm.SelectTabCommand.Execute(tab); var currentViewModel = vm.CurrentTabViewModel; Assert.NotNull(currentViewModel); @@ -236,7 +248,6 @@ private static (SettingsViewModel SettingsVm, Mock UserSet var mockInstallationService = new Mock(); var mockStorageLocationService = new Mock(); var mockUserDataTracker = new Mock(); - var mockGitHubTokenStorage = new Mock(); var settingsVm = new SettingsViewModel( mockUserSettings.Object, @@ -250,8 +261,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockConfigurationProvider.Object, mockInstallationService.Object, mockStorageLocationService.Object, - mockUserDataTracker.Object, - mockGitHubTokenStorage.Object); + mockUserDataTracker.Object); return (settingsVm, mockUserSettings); } @@ -277,7 +287,7 @@ private static DownloadsViewModel CreateDownloadsViewModel() var mockGitHubClient = new Mock(); var mockDiscovererLogger = new Mock>(); - // Instantiate the real class with the two mocks + // Instantiate the real class with the three mocks var realGitHubDiscoverer = new GitHubTopicsDiscoverer( mockGitHubClient.Object, mockDiscovererLogger.Object); @@ -335,6 +345,30 @@ private static GameProfileLauncherViewModel CreateGameProfileLauncherViewModel() NullLogger.Instance); } + private static SuperHackersProvider CreateSuperHackersProvider() + { + var discovererMock = new Mock(); + discovererMock.Setup(x => x.SourceName).Returns("GitHubReleasesDiscoverer"); + + var resolverMock = new Mock(); + resolverMock.Setup(x => x.ResolverId).Returns(GenHub.Core.Constants.SuperHackersConstants.ResolverId); + + var delivererMock = new Mock(); + delivererMock.Setup(x => x.SourceName).Returns(GenHub.Core.Constants.ContentSourceNames.GitHubDeliverer); + + var gitHubApiClientMock = new Mock(); + + var loaderMock = new Mock(); + + return new SuperHackersProvider( + loaderMock.Object, + gitHubApiClientMock.Object, + [resolverMock.Object], + [delivererMock.Object], + new Mock().Object, + NullLogger.Instance); + } + private static Mock CreateNotificationServiceMock() { var mock = new Mock(); @@ -349,11 +383,4 @@ private static ProfileResourceService CreateProfileResourceService() { return new ProfileResourceService(NullLogger.Instance); } - - private static NotificationFeedViewModel CreateNotificationFeedViewModel(INotificationService notificationService) - { - var mockLoggerFactory = new Mock(); - var mockLogger = new Mock>(); - return new NotificationFeedViewModel(notificationService, mockLoggerFactory.Object, mockLogger.Object); - } -} +} \ No newline at end of file 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/Features/Validation/FileSystemValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs index f4fc7f283..dc9193923 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Validation/FileSystemValidatorTests.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; using Moq; -namespace GenHub.Tests.Features.Validation; +namespace GenHub.Tests.Core.Features.Validation; /// /// Unit tests for FileSystemValidator. @@ -80,17 +80,8 @@ public async Task ValidateFilesAsync_IOException_ReportsIssue() /// /// Test implementation of for unit testing. /// - public class TestFileSystemValidator : FileSystemValidator + public class TestFileSystemValidator(ILogger logger) : FileSystemValidator(logger, new Mock().Object) { - /// - /// Initializes a new instance of the class. - /// - /// Logger instance. - public TestFileSystemValidator(ILogger logger) - : base(logger, new Mock().Object) - { - } - /// /// Exposes base ValidateDirectoriesAsync for testing. /// @@ -98,8 +89,8 @@ public TestFileSystemValidator(ILogger logger) /// Directories to check. /// Cancellation token. /// List of validation issues. - public new Task> ValidateDirectoriesAsync(string basePath, IEnumerable requiredDirectories, CancellationToken cancellationToken) - => FileSystemValidator.ValidateDirectoriesAsync(basePath, requiredDirectories, cancellationToken); + public new async Task> ValidateDirectoriesAsync(string basePath, IEnumerable requiredDirectories, CancellationToken cancellationToken) + => await base.ValidateDirectoriesAsync(basePath, requiredDirectories, cancellationToken); /// /// Exposes base ValidateFilesAsync for testing. @@ -109,7 +100,7 @@ public TestFileSystemValidator(ILogger logger) /// Cancellation token. /// Progress reporter. /// List of validation issues. - public new Task> ValidateFilesAsync(string basePath, IEnumerable files, CancellationToken cancellationToken, IProgress? progress = null) - => base.ValidateFilesAsync(basePath, files, cancellationToken, progress); + public new async Task> ValidateFilesAsync(string basePath, IEnumerable files, CancellationToken cancellationToken, IProgress? progress = null) + => await base.ValidateFilesAsync(basePath, files, cancellationToken, progress); } } \ No newline at end of file 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..c23cdceb9 --- /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 Mock _loggerMock; + private 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_LogsAndCallsInternal() + { + 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) => Task.FromResult(true); + + public override Task IsAppliedAsync(GameInstallation installation) => 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/Fixes/EAAppRegistryFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs new file mode 100644 index 000000000..8bd8bd0e2 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.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; + +/// +/// 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_WhenGeneralsKeysMissing() + { + 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_WhenErgcMissing() + { + 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, "Version", 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, "Version", 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_SetsRegistryKeys() + { + 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, "Version", 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)); + } +} 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..74f7bd6ab --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs @@ -0,0 +1,198 @@ +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.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) +/// and adds Windows Defender exclusions for game executables. +/// +public class AppCompatConfigurationsFix( + IRegistryService registryService, + ILogger logger) : BaseActionSet(logger) +{ + private static readonly string[] GeneralsExecutables = ["Generals.exe", "generals.exe", "generalsv.exe"]; + private static readonly string[] ZeroHourExecutables = ["Generals.exe", "generals.exe", "generalszh.exe", "GeneralsOnlineZH.exe", "GeneralsOnlineZH_30.exe", "GeneralsOnlineZH_60.exe"]; + + private readonly IRegistryService _registryService = registryService ?? throw new ArgumentNullException(nameof(registryService)); + private readonly ILogger _logger = logger; + + /// + public override string Id => "AppCompatConfigurationsFix"; + + /// + public override string Title => "Windows Compatibility Configurations"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) => Task.FromResult(true); + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + string expectedFlag = installation.InstallationType == GameInstallationType.Steam + ? "~ HIGHDPIAWARE" + : "~ RUNASADMIN HIGHDPIAWARE"; + + if (installation.HasGenerals) + { + foreach (var exe in GeneralsExecutables) + { + var fullPath = Path.Combine(installation.GeneralsPath, exe); + if (File.Exists(fullPath)) + { + var current = _registryService.GetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath); + if (current != expectedFlag) return Task.FromResult(false); + } + } + } + + if (installation.HasZeroHour) + { + foreach (var exe in ZeroHourExecutables) + { + var fullPath = Path.Combine(installation.ZeroHourPath, exe); + if (File.Exists(fullPath)) + { + var current = _registryService.GetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath); + if (current != expectedFlag) 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 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); + + if (installation.HasGenerals) + { + details.Add($"Processing Generals executables: {installation.GeneralsPath}"); + await ProcessExecutablesAsync(installation.GeneralsPath, GeneralsExecutables, flag, details, ct); + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour executables: {installation.ZeroHourPath}"); + await ProcessExecutablesAsync(installation.ZeroHourPath, ZeroHourExecutables, flag, details, ct); + } + + 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 cancellationToken) + { + _logger.LogWarning("Undoing Windows Compatibility Configurations is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } + + private async Task ProcessExecutablesAsync(string installPath, string[] executables, string flag, List details, CancellationToken ct) + { + int processedCount = 0; + int defenderCount = 0; + + foreach (var exe in executables) + { + ct.ThrowIfCancellationRequested(); + + var fullPath = Path.Combine(installPath, exe); + if (!File.Exists(fullPath)) continue; + + // 1. Set Registry AppCompat Flag + try + { + _registryService.SetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath, flag); + details.Add($" ✓ Set compatibility flags for: {exe}"); + processedCount++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to set registry flag for {Path}", fullPath); + details.Add($" ✗ Failed to set flags for: {exe}"); + } + + // 2. Add Windows Defender Exclusion + var defenderResult = await AddDefenderExclusionAsync(fullPath, ct); + if (defenderResult) + { + details.Add($" ✓ Added Windows Defender exclusion for: {exe}"); + defenderCount++; + } + else + { + details.Add($" ⚠ Could not add Defender exclusion for: {exe}"); + } + } + + details.Add($"✓ Processed {processedCount} executables"); + details.Add($"✓ Added {defenderCount} Windows Defender exclusions"); + } + + private async Task AddDefenderExclusionAsync(string path, CancellationToken ct) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-WindowStyle Hidden -NoProfile -NonInteractive -Command \"Add-MpPreference -ExclusionPath \\\"{path}\\\"\"", + CreateNoWindow = true, + UseShellExecute = true, // Required for admin prompt if not already admin + Verb = "runas", + }; + + using var process = Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(ct); + return process.ExitCode == 0; + } + + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to add Defender exclusion for {Path}", path); + 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..14926f80b --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs @@ -0,0 +1,173 @@ +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 for the BrowserEngine.dll which causes crashes on modern systems. +/// +public class BrowserEngineFix(ILogger logger) : BaseActionSet(logger) +{ + // Use constants from GameClientConstants + private const string BrowserEngineDll = GameClientConstants.BrowserEngineDll; + private const string BrowserEngineDllBak = GameClientConstants.BrowserEngineDllBak; + + /// + public override string Id => "BrowserEngineFix"; + + /// + public override string Title => "Browser Engine DLL Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Applicable if the file exists in either Generals or Zero Hour path + if (installation.HasGenerals && File.Exists(Path.Combine(installation.GeneralsPath, BrowserEngineDll))) + { + return Task.FromResult(true); + } + + if (installation.HasZeroHour && File.Exists(Path.Combine(installation.ZeroHourPath, BrowserEngineDll))) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + // Considered applied if the .bak file exists (indicating we renamed it) + bool generalsApplied = !installation.HasGenerals || File.Exists(Path.Combine(installation.GeneralsPath, BrowserEngineDllBak)); + bool zeroHourApplied = !installation.HasZeroHour || File.Exists(Path.Combine(installation.ZeroHourPath, BrowserEngineDllBak)); + + return Task.FromResult(generalsApplied && zeroHourApplied); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting BrowserEngine.dll fix..."); + details.Add("This DLL causes crashes on modern systems and will be disabled"); + + if (installation.HasGenerals) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + var result = RenameDll(installation.GeneralsPath, details); + if (!result) + { + details.Add(" ⚠ BrowserEngine.dll not found (may already be fixed)"); + } + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + var result = RenameDll(installation.ZeroHourPath, details); + if (!result) + { + details.Add(" ⚠ BrowserEngine.dll not found (may already be fixed)"); + } + } + + details.Add("✓ BrowserEngine.dll fix completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + 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 BrowserEngine.dll..."); + + if (installation.HasGenerals) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + RestoreDll(installation.GeneralsPath, details); + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + RestoreDll(installation.ZeroHourPath, details); + } + + details.Add("✓ BrowserEngine.dll restored"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private static bool RenameDll(string path, List details) + { + var dllPath = Path.Combine(path, BrowserEngineDll); + var bakPath = Path.Combine(path, BrowserEngineDllBak); + + if (File.Exists(dllPath)) + { + if (File.Exists(bakPath)) + { + File.Delete(bakPath); + details.Add($" • Deleted existing backup: {BrowserEngineDllBak}"); + } + + File.Move(dllPath, bakPath); + details.Add($" ✓ Renamed {BrowserEngineDll} → {BrowserEngineDllBak}"); + return true; + } + + return false; + } + + private static void RestoreDll(string path, List details) + { + var dllPath = Path.Combine(path, BrowserEngineDll); + var bakPath = Path.Combine(path, BrowserEngineDllBak); + + if (File.Exists(bakPath)) + { + if (File.Exists(dllPath)) + { + File.Delete(dllPath); + } + + File.Move(bakPath, dllPath); + details.Add($" ✓ Restored {BrowserEngineDllBak} → {BrowserEngineDll}"); + } + else + { + details.Add($" ⚠ Backup file not found: {BrowserEngineDllBak}"); + } + } +} 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..4806f1a8f --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs @@ -0,0 +1,153 @@ +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 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) +{ + private readonly IRegistryService _registryService = registryService; + private readonly ILogger _logger = logger; + + /// + public override string Id => "CncOnlineLauncherFix"; + + /// + public override string Title => "C&C Online Launcher Fix"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check if C&C Online registry entries exist + var cncOnlineInstalled = _registryService.GetStringValue( + RegistryConstants.CncOnlineKeyPath, + RegistryConstants.InstallPathValueName); + + return Task.FromResult(!string.IsNullOrEmpty(cncOnlineInstalled)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking C&C Online registry status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + details.Add("Starting C&C Online registry configuration..."); + + // Create C&C Online registry entries for Generals + if (installation.HasGenerals) + { + details.Add($"Configuring C&C Online for Generals at: {installation.GeneralsPath}"); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineGeneralsKeyPath, + RegistryConstants.InstallPathValueName, + installation.GeneralsPath); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineGeneralsKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.CncOnlineGeneralsVersion); + + details.Add("✓ Created: HKCU\\SOFTWARE\\Revora\\CNCOnline\\Generals"); + details.Add($" • InstallPath = {installation.GeneralsPath}"); + details.Add(" • Version = 1.08"); + + _logger.LogInformation("Created C&C Online registry entries for Generals"); + } + + // Create C&C Online registry entries for Zero Hour + if (installation.HasZeroHour) + { + details.Add($"Configuring C&C Online for Zero Hour at: {installation.ZeroHourPath}"); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineZeroHourKeyPath, + RegistryConstants.InstallPathValueName, + installation.ZeroHourPath); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineZeroHourKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.CncOnlineZeroHourVersion); + + details.Add("✓ Created: HKCU\\SOFTWARE\\Revora\\CNCOnline\\ZeroHour"); + details.Add($" • InstallPath = {installation.ZeroHourPath}"); + details.Add(" • Version = 1.04"); + + _logger.LogInformation("Created C&C Online registry entries for Zero Hour"); + } + + // Create main C&C Online entry + var basePath = installation.HasGenerals + ? installation.GeneralsPath + : installation.ZeroHourPath; + + details.Add("Creating main C&C Online registry entry..."); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineKeyPath, + RegistryConstants.InstallPathValueName, + basePath); + + _registryService.SetStringValue( + RegistryConstants.CncOnlineKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.CncOnlineVersion); + + details.Add("✓ Created: HKCU\\SOFTWARE\\Revora\\CNCOnline"); + details.Add($" • InstallPath = {basePath}"); + details.Add(" • Version = 1.0"); + 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 cancellationToken) + { + _logger.LogWarning("Undoing C&C Online Registry Fix is not recommended as it may break multiplayer functionality."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..cbf1b4ee2 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs @@ -0,0 +1,146 @@ +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.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 string[] RequiredDLLs = + [ + "d3d8.dll", + "d3d8thk.dll", + "d3dx9_43.dll", + ]; + + private readonly ILogger _logger = logger; + + /// + public override string Id => "D3D8XDLLCheck"; + + /// + public override string Title => "DirectX 8 DLL Check"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check if required DirectX DLLs are present in system directories + var system32 = Environment.GetFolderPath(Environment.SpecialFolder.System); + var sysWow64 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "SysWOW64"); + var gameDir = installation.InstallationPath; + + var allPresent = true; + var missingDLLs = 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 = !string.IsNullOrEmpty(gameDir) && File.Exists(Path.Combine(gameDir, dll)); + + if (!inSystem32 && !inSysWow64 && !inGameDir) + { + allPresent = false; + missingDLLs.Add(dll); + } + } + + 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 cancellationToken) + { + try + { + // This fix is informational - it checks for DLLs and provides guidance + // The actual DirectX installation is handled by DirectXRuntimeFix + var system32 = Environment.GetFolderPath(Environment.SpecialFolder.System); + var sysWow64 = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "SysWOW64"); + + var missingDLLs = new List(); + + foreach (var dll in RequiredDLLs) + { + var inSystem32 = File.Exists(Path.Combine(system32, dll)); + var inSysWow64 = File.Exists(Path.Combine(sysWow64, dll)); + + if (!inSystem32 && !inSysWow64) + { + missingDLLs.Add(dll); + } + } + + 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:"); + foreach (var dll in missingDLLs) + { + _logger.LogWarning(" - {DLL}", dll); + } + + _logger.LogInformation("To fix this issue:"); + _logger.LogInformation("1. Run DirectXRuntimeFix to install DirectX 8.1/9.0c runtime"); + _logger.LogInformation("2. This will install all required DirectX 8 DLLs"); + _logger.LogInformation("3. Restart your computer after installation"); + + return Task.FromResult(new ActionSetResult(true, null, [$"Missing {missingDLLs.Count} DirectX 8 DLLs in system directories. Please run DirectXRuntimeFix."])); + } + 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 cancellationToken) + { + _logger.LogWarning("D3D8XDLLCheck is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..0c13c1927 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DbgHelpFix.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 Microsoft.Extensions.Logging; + +/// +/// Fix for the dbghelp.dll which causes crashes on modern systems. +/// +public class DbgHelpFix(ILogger logger) : BaseActionSet(logger) +{ + // Use constants from GameClientConstants + private const string DbgHelpDll = GameClientConstants.DbgHelpDll; + private const string DbgHelpDllBak = GameClientConstants.DbgHelpDllBak; + + /// + public override string Id => "DbgHelpFix"; + + /// + public override string Title => "Debug Help DLL Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Applicable if the file exists in either Generals or Zero Hour path + // This fix is needed because the old dbghelp.dll causes crashes on modern Windows + if (installation.HasGenerals && File.Exists(Path.Combine(installation.GeneralsPath, DbgHelpDll))) + { + return Task.FromResult(true); + } + + if (installation.HasZeroHour && File.Exists(Path.Combine(installation.ZeroHourPath, DbgHelpDll))) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + // Considered applied if the DLL is missing (renamed) in all present installations + bool generalsOk = !installation.HasGenerals || !File.Exists(Path.Combine(installation.GeneralsPath, DbgHelpDll)); + bool zeroHourOk = !installation.HasZeroHour || !File.Exists(Path.Combine(installation.ZeroHourPath, DbgHelpDll)); + + return Task.FromResult(generalsOk && zeroHourOk); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct) + { + var details = new List(); + + try + { + details.Add("Starting DbgHelp.dll fix..."); + details.Add("This DLL causes crashes on modern Windows and will be disabled"); + + if (installation.HasGenerals) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + var result = RenameDll(installation.GeneralsPath, details); + if (!result) + { + details.Add(" ⚠ DbgHelp.dll not found (may already be fixed)"); + } + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + var result = RenameDll(installation.ZeroHourPath, details); + if (!result) + { + details.Add(" ⚠ DbgHelp.dll not found (may already be fixed)"); + } + } + + details.Add("✓ DbgHelp.dll fix completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + 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 DbgHelp.dll..."); + + if (installation.HasGenerals) + { + details.Add($"Processing Generals: {installation.GeneralsPath}"); + RestoreDll(installation.GeneralsPath, details); + } + + if (installation.HasZeroHour) + { + details.Add($"Processing Zero Hour: {installation.ZeroHourPath}"); + RestoreDll(installation.ZeroHourPath, details); + } + + details.Add("✓ DbgHelp.dll restored"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + private static bool RenameDll(string path, List details) + { + var dllPath = Path.Combine(path, DbgHelpDll); + var bakPath = Path.Combine(path, DbgHelpDllBak); + + if (File.Exists(dllPath)) + { + if (File.Exists(bakPath)) + { + File.Delete(bakPath); + details.Add($" • Deleted existing backup: {DbgHelpDllBak}"); + } + + File.Move(dllPath, bakPath); + details.Add($" ✓ Renamed {DbgHelpDll} → {DbgHelpDllBak}"); + return true; + } + + return false; + } + + private static void RestoreDll(string path, List details) + { + var dllPath = Path.Combine(path, DbgHelpDll); + var bakPath = Path.Combine(path, DbgHelpDllBak); + + if (File.Exists(bakPath)) + { + if (File.Exists(dllPath)) + { + File.Delete(dllPath); + } + + File.Move(bakPath, dllPath); + details.Add($" ✓ Restored {DbgHelpDllBak} → {DbgHelpDll}"); + } + else + { + details.Add($" ⚠ Backup file not found: {DbgHelpDllBak}"); + } + } +} 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..29856de38 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DirectXRuntimeFix.cs @@ -0,0 +1,253 @@ +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.Models.GameInstallations; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Windows.Features.ActionSets.Fixes; + +/// +/// 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) +{ + private readonly ILogger _logger = logger; + + /// + public override string Id => "DirectXRuntimeFix"; + + /// + public override string Title => "DirectX Runtime Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; // Network failures shouldn't abort entire sequence + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // This fix is applicable regardless of installation type as it's a system dependency + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + 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 cancellationToken) + { + var details = new List(); + var tempFolder = Path.Combine(Path.GetTempPath(), "GenHub_DirectX"); + var zipFile = Path.Combine(tempFolder, "dx_runtime.zip"); + var extractPath = Path.Combine(tempFolder, "Extracted"); + + try + { + details.Add("Starting DirectX Runtime installation..."); + details.Add($"Download URL: {ExternalUrls.DirectXRuntimeDownloadUrl}"); + + if (Directory.Exists(tempFolder)) + { + Directory.Delete(tempFolder, true); + } + + Directory.CreateDirectory(extractPath); + details.Add($"Temp directory: {tempFolder}"); + + details.Add("Downloading DirectX Runtime..."); + + using var client = httpClientFactory.CreateClient(); + + // Add User-Agent to avoid blocking + 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"); + + // Increase timeout for large downloads (DirectX is ~100MB) + client.Timeout = TimeSpan.FromMinutes(5); + + var urls = new[] + { + ExternalUrls.DirectXRuntimeDownloadUrlPrimary, + ExternalUrls.DirectXRuntimeDownloadUrlMirror1, + }; + bool downloaded = false; + + var isExe = false; + var downloadPath = string.Empty; + + foreach (var url in urls) + { + try + { + _logger.LogInformation("Attempting download from {Url}", url); + + var uri = new Uri(url); + isExe = uri.AbsolutePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + downloadPath = isExe ? Path.Combine(tempFolder, "dxsetup.exe") : zipFile; + + var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fileSize = response.Content.Headers.ContentLength ?? 0; + + // Validate file size - 200KB for web installer, 1MB for zip + var minSize = isExe ? 200 * 1024 : 1024 * 1024; + + if (fileSize < minSize) + { + _logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Likely blocked.", url, fileSize); + continue; + } + + details.Add($"✓ Downloaded {fileSize / 1024.0 / 1024.0:F2} MB from {uri.Host}"); + + _logger.LogInformation("Reading response content to memory..."); + var fileBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + + _logger.LogInformation("Writing {Size} bytes to disk...", fileBytes.Length); + await File.WriteAllBytesAsync(downloadPath, fileBytes, cancellationToken); + + if (!isExe) + { + // Validate ZIP integrity + try + { + using var archive = ZipFile.OpenRead(downloadPath); + var entryCount = archive.Entries.Count; + _logger.LogInformation("Validated zip archive from {Url} ({Count} entries)", url, entryCount); + } + catch (Exception ex) + { + _logger.LogWarning("Downloaded file from {Url} is corrupt: {Error}. Trying next mirror.", url, ex.Message); + continue; + } + } + + downloaded = true; + break; + } + catch (Exception ex) + { + _logger.LogWarning("Failed to download from {Url}: {Error}", url, ex.Message); + } + } + + if (!downloaded) + { + throw new HttpRequestException("Failed to download or validate DirectX Runtime from all mirrors."); + } + + string setupExe; + string arguments = string.Empty; + + if (isExe) + { + setupExe = downloadPath; + arguments = "/Q"; // Silent install for web setup + details.Add("Running DirectX Web Setup..."); + } + else + { + 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"); + + setupExe = Path.Combine(extractPath, "DXSETUP.exe"); + if (!File.Exists(setupExe)) + { + details.Add("✗ DXSETUP.exe not found in package"); + return new ActionSetResult(false, "DXSETUP.exe not found in downloaded package.", details); + } + } + + details.Add($"Running DirectX Setup (silent mode)..."); + details.Add(" ⚠ This may require administrator privileges"); + _logger.LogInformation("Running DirectX Setup (Silent)..."); + + 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(cancellationToken); + + if (process.ExitCode != 0) + { + _logger.LogWarning("DirectX setup exited with code {ExitCode}", process.ExitCode); + details.Add($"⚠ DirectX setup exited with code {process.ExitCode}"); + details.Add(" Note: Non-zero codes may not indicate failure"); + } + else + { + details.Add("✓ DirectX setup completed successfully"); + } + + details.Add("✓ DirectX Runtime installation completed"); + + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error implementing DirectX Runtime Fix"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + try + { + if (Directory.Exists(tempFolder)) + { + Directory.Delete(tempFolder, true); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to cleanup temp directory: {TempFolder}", tempFolder); + } + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Uninstalling DirectX Runtime is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..45224121e --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/DisableOriginInGame.cs @@ -0,0 +1,160 @@ +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 ILogger _logger = 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 bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + return Task.FromResult(File.Exists(_markerPath)); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + try + { + var originInstalled = IsOriginInstalled(); + + if (!originInstalled) + { + _logger.LogInformation("Origin is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Check if overlay is already disabled + if (IsOriginOverlayDisabled()) + { + _logger.LogInformation("Origin in-game overlay is already disabled."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Provide guidance for disabling Origin overlay + _logger.LogWarning("Origin in-game overlay is enabled. This may cause performance issues."); + _logger.LogInformation("To disable Origin in-game overlay:"); + _logger.LogInformation("1. Open Origin client"); + _logger.LogInformation("2. Go to 'Application Settings' (gear icon)"); + _logger.LogInformation("3. Select 'Origin In-Game'"); + _logger.LogInformation("4. Uncheck 'Enable Origin In-Game'"); + _logger.LogInformation("5. Click 'Save'"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, you can disable it per game:"); + _logger.LogInformation("1. Right-click on Generals or Zero Hour in Origin"); + _logger.LogInformation("2. Select 'Game Properties'"); + _logger.LogInformation("3. Uncheck 'Enable Origin In-Game for this game'"); + _logger.LogInformation("4. Click 'Save'"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create marker file for DisableOriginInGame"); + } + + return Task.FromResult(new ActionSetResult(true, "Please manually disable Origin in-game overlay. See logs for details.")); + } + 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 cancellationToken) + { + _logger.LogWarning("Disable Origin In-Game Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool IsOriginInstalled() + { + try + { + // Check for Origin in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + RegistryConstants.OriginKeyPath, + false); + + if (key != null) + { + return true; + } + + // Check for Origin processes + var processes = Process.GetProcessesByName("Origin"); + return processes.Length > 0; + } + 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 configContent = File.ReadAllText(originConfigPath); + + // Check if overlay is disabled + // The setting is typically in the format: [General] OverlayEnabled=0 + return configContent.Contains("OverlayEnabled=0", StringComparison.OrdinalIgnoreCase) || + configContent.Contains("OverlayEnabled=false", StringComparison.OrdinalIgnoreCase); + } + 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..ec36b55b2 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EAAppRegistryFix.cs @@ -0,0 +1,262 @@ +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 readonly IRegistryService _registryService = registryService ?? throw new ArgumentNullException(nameof(registryService)); + + /// + public override string Id => "EAAppRegistryFix"; + + /// + public override string Title => "EA App Registry Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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); + } + + // Applicable if keys are missing or point to wrong location + bool fixNeeded = false; + + if (installation.HasGenerals) + { + var installPath = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName); + var version = _registryService.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName); + var serial = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); // Default value name is empty string + + if (!string.Equals(installPath, installation.GeneralsPath, StringComparison.OrdinalIgnoreCase) || + version != RegistryConstants.GeneralsVersionDWord || + string.IsNullOrEmpty(serial)) + { + fixNeeded = true; + } + } + + if (installation.HasZeroHour) + { + var installPath = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName); + var version = _registryService.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName); + var serial = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + + if (!string.Equals(installPath, installation.ZeroHourPath, StringComparison.OrdinalIgnoreCase) || + version != RegistryConstants.ZeroHourVersionDWord || + string.IsNullOrEmpty(serial)) + { + fixNeeded = true; + } + } + + return Task.FromResult(fixNeeded); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + if (installation.HasGenerals) + { + var installPath = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName); + var version = _registryService.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName); + var serial = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); + + if (!string.Equals(installPath, installation.GeneralsPath, StringComparison.OrdinalIgnoreCase) || + version != RegistryConstants.GeneralsVersionDWord || + string.IsNullOrEmpty(serial)) + { + return Task.FromResult(false); + } + } + + if (installation.HasZeroHour) + { + var installPath = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName); + var version = _registryService.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName); + var serial = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + + if (!string.Equals(installPath, installation.ZeroHourPath, StringComparison.OrdinalIgnoreCase) || + version != RegistryConstants.ZeroHourVersionDWord || + string.IsNullOrEmpty(serial)) + { + return Task.FromResult(false); + } + } + + return Task.FromResult(true); + } + + /// + 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(" Registry modifications require elevated permissions"); + return Task.FromResult(new ActionSetResult(false, "Administrator privileges are required to modify registry keys. Please restart GenHub as administrator.", details)); + } + + try + { + details.Add("Starting EA App registry configuration..."); + bool allSucceeded = true; + var failedOperations = new List(); + + if (installation.HasGenerals) + { + details.Add($"Configuring EA App registry for Generals: {installation.GeneralsPath}"); + + if (!_registryService.SetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, installation.GeneralsPath)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppGeneralsKeyPath}\\{RegistryConstants.InstallPathValueName}"); + details.Add($" ✗ Failed to set InstallPath"); + } + else + { + details.Add($" ✓ InstallPath = {installation.GeneralsPath}"); + } + + if (!_registryService.SetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, RegistryConstants.GeneralsVersionDWord)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppGeneralsKeyPath}\\{RegistryConstants.VersionValueName}"); + details.Add($" ✗ Failed to set Version"); + } + else + { + details.Add($" ✓ Version = {RegistryConstants.GeneralsVersionDWord}"); + } + + var existingSerial = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); + if (string.IsNullOrEmpty(existingSerial)) + { + const string defaultSerial = "1234567890"; + if (!_registryService.SetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, defaultSerial)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppGeneralsErgcKeyPath}\\(Default)"); + details.Add($" ✗ Failed to set serial key"); + } + else + { + details.Add($" ✓ Serial key created: {defaultSerial}"); + } + } + else + { + details.Add($" ✓ Serial key already exists"); + } + + if (allSucceeded) + { + details.Add("✓ Generals registry configuration completed"); + } + } + + if (installation.HasZeroHour) + { + details.Add($"Configuring EA App registry for Zero Hour: {installation.ZeroHourPath}"); + + if (!_registryService.SetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName, installation.ZeroHourPath)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppZeroHourKeyPath}\\{RegistryConstants.InstallPathValueName}"); + details.Add($" ✗ Failed to set InstallPath"); + } + else + { + details.Add($" ✓ InstallPath = {installation.ZeroHourPath}"); + } + + if (!_registryService.SetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName, RegistryConstants.ZeroHourVersionDWord)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppZeroHourKeyPath}\\{RegistryConstants.VersionValueName}"); + details.Add($" ✗ Failed to set Version"); + } + else + { + details.Add($" ✓ Version = {RegistryConstants.ZeroHourVersionDWord}"); + } + + var existingSerial = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + if (string.IsNullOrEmpty(existingSerial)) + { + const string defaultSerial = "1234567890"; + if (!_registryService.SetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty, defaultSerial)) + { + allSucceeded = false; + failedOperations.Add($"{RegistryConstants.EAAppZeroHourErgcKeyPath}\\(Default)"); + details.Add($" ✗ Failed to set serial key"); + } + else + { + details.Add($" ✓ Serial key created: {defaultSerial}"); + } + } + else + { + details.Add($" ✓ Serial key already exists"); + } + + if (allSucceeded) + { + details.Add("✓ Zero Hour registry configuration completed"); + } + } + + if (!allSucceeded) + { + details.Add($"✗ Failed to write {failedOperations.Count} registry key(s)"); + foreach (var op in failedOperations) + { + details.Add($" • {op}"); + } + + return Task.FromResult(new ActionSetResult(false, $"Failed to write the following registry keys: {string.Join(", ", failedOperations)}. Ensure you are running as administrator.", details)); + } + + details.Add("✓ EA App registry configuration completed successfully"); + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + // Undoing registry fixes is tricky - usually we don't want to revert to a broken state. + return Task.FromResult(Success()); + } +} 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..ac340a8af --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/EdgeScrollerFix.cs @@ -0,0 +1,185 @@ +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) +{ + private readonly ILogger _logger = logger; + private readonly IGameSettingsService _gameSettingsService = gameSettingsService; + + /// + public override string Id => "EdgeScrollerFix"; + + /// + public override string Title => "Edge Scrolling Fix"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override async Task IsAppliedAsync(GameInstallation installation) + { + 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 cancellationToken) + { + try + { + var details = new List(); + + if (installation.HasGenerals) + { + var gameDetails = await ApplyEdgeScrollingFixAsync(GameType.Generals); + details.AddRange(gameDetails); + } + + if (installation.HasZeroHour) + { + var gameDetails = await ApplyEdgeScrollingFixAsync(GameType.ZeroHour); + details.AddRange(gameDetails); + } + + if (details.Count == 0) + { + details.Add("No games found to apply edge scrolling fix to."); + } + + 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 Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Undoing Edge Scrolling Fix is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true, null, ["Undo not supported for Edge Scrolling Fix."])); + } + + 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("TheSuperHackers", out var tshSection)) + { + return false; + } + + // If either setting exists, consider the fix applied + return tshSection.ContainsKey("ScrollEdgeZone") || tshSection.ContainsKey("ScrollEdgeSpeed"); + } + + private async Task> 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; + } + + 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] = "0"; + tshSection[ActionSetConstants.IniFiles.ScrollEdgeSpeedKey] = "1.0"; + tshSection[ActionSetConstants.IniFiles.ScrollEdgeAccelerationKey] = "0.0"; + + // Also ensure default scroll factor is good if present + if (tshSection.ContainsKey("ScrollFactor")) + { + tshSection["ScrollFactor"] = "60"; + details.Add($"✓ Set ScrollFactor=60 for {gameType}"); + } + + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeZoneKey}=0 for {gameType}"); + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeSpeedKey}=1.0 for {gameType}"); + details.Add($"✓ Set {ActionSetConstants.IniFiles.ScrollEdgeAccelerationKey}=0.0 for {gameType}"); + + await _gameSettingsService.SaveOptionsAsync(gameType, options); + + details.Add($"✓ Saved Options.ini: {optionsPath}"); + _logger.LogInformation("Successfully applied edge scrolling fix for {GameType}", gameType); + } + 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; + } +} 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..9fdaa7f16 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenu.cs @@ -0,0 +1,99 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides guidance for expanded LAN lobby menu. +/// This fix explains how to access and use LAN features in Generals and Zero Hour. +/// +public class ExpandedLANLobbyMenu(ILogger logger) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", "sub_markers", "ExpandedLANLobbyMenu.done"); + + /// + public override string Id => "ExpandedLANLobbyMenu"; + + /// + public override string Title => "Expanded LAN Lobby Menu"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + return Task.FromResult(File.Exists(_markerPath)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking LAN lobby menu status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + try + { + // Provide guidance for LAN play + _logger.LogInformation("LAN Lobby Menu Information:"); + _logger.LogInformation("Generals and Zero Hour have built-in LAN support."); + _logger.LogInformation(string.Empty); + _logger.LogInformation("To play on LAN:"); + _logger.LogInformation("1. Ensure all players are on the same network"); + _logger.LogInformation("2. Launch the game"); + _logger.LogInformation("3. Go to 'Multiplayer' > 'Network' > 'LAN'"); + _logger.LogInformation("4. Create or host a LAN game"); + _logger.LogInformation("5. Other players can join from the LAN lobby"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Note: For best LAN experience:"); + _logger.LogInformation("- Ensure Windows Firewall allows the game"); + _logger.LogInformation("- Disable VPN if not needed"); + _logger.LogInformation("- Use wired network connection if possible"); + _logger.LogInformation("- Ensure all players have the same game version"); + _logger.LogInformation(string.Empty); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch + { + } + + return Task.FromResult(new ActionSetResult(true, "LAN lobby menu is built into the game. See logs for details.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying LAN lobby menu fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Expanded LAN Lobby Menu Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..9730aa9da --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/FirewallExceptionFix.cs @@ -0,0 +1,370 @@ +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 readonly ILogger _logger = logger; + + /// + public override string Id => "FirewallExceptionFix"; + + /// + public override string Title => "Windows Firewall Exceptions"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check for GenPatcher's primary rule - if this exists, fix is applied + // This matches GenPatcher's PerformIsApplied() which checks "GP Open UDP Port 16000" + 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 cancellationToken) + { + var details = new List(); + + try + { + // Check if already applied + 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); + } + + // Run firewall commands asynchronously to avoid UI blocking + await Task.Run( + () => + { + // Add port rules (like GenPatcher does) + if (AddPortRule(PortRuleUdp16000, "UDP", 16000)) + { + details.Add($"✓ Added rule: {PortRuleUdp16000}"); + } + else + { + details.Add($"⚠ Failed: {PortRuleUdp16000}"); + } + + if (AddPortRule(PortRuleUdp16001, "UDP", 16001)) + { + details.Add($"✓ Added rule: {PortRuleUdp16001}"); + } + else + { + details.Add($"⚠ Failed: {PortRuleUdp16001}"); + } + + if (AddPortRule( + PortRuleTcp16001, + "TCP", + 16001)) + { + details.Add($"✓ Added rule: {PortRuleTcp16001}"); + } + else + { + details.Add($"⚠ Failed: {PortRuleTcp16001}"); + } + + // Add Generals executable rules + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var generalsExe = Path.Combine(installation.GeneralsPath, "Generals.exe"); + var generalsGameDat = Path.Combine(installation.GeneralsPath, "Game.dat"); + + if (File.Exists(generalsExe)) + { + if (AddProgramRule(GeneralsRule, generalsExe)) + { + details.Add($"✓ Added rule: {GeneralsRule}"); + } + else + { + details.Add($"⚠ Failed: {GeneralsRule}"); + } + } + + if (File.Exists(generalsGameDat)) + { + if (AddProgramRule(GeneralsGameDatRule, generalsGameDat)) + { + details.Add($"✓ Added rule: {GeneralsGameDatRule}"); + } + else + { + details.Add($"⚠ Failed: {GeneralsGameDatRule}"); + } + } + } + + // Add Zero Hour executable rules + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + // NOTE: Zero Hour often runs via generals.exe (the engine), not the launcher. + // However, we add rules for both standard executables just in case. + + // Add Zero Hour executable rule + var zeroHourExe = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GeneralsExe); + var zeroHourGameDat = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameDat); + if (File.Exists(zeroHourExe)) + { + if (AddProgramRule(ZeroHourRule, zeroHourExe)) + { + details.Add($"✓ Added rule: {ZeroHourRule}"); + } + else + { + details.Add($"⚠ Failed: {ZeroHourRule}"); + } + } + + if (File.Exists(zeroHourGameDat)) + { + if (AddProgramRule(ZeroHourGameDatRule, zeroHourGameDat)) + { + details.Add($"✓ Added rule: {ZeroHourGameDatRule}"); + } + else + { + details.Add($"⚠ Failed: {ZeroHourGameDatRule}"); + } + } + } + }, + cancellationToken); + + _logger.LogInformation("Firewall rules applied. Details: {Details}", string.Join("; ", details)); + 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 cancellationToken) + { + var details = new List(); + + try + { + await Task.Run( + () => + { + // Remove all GP rules (like GenPatcher does - runs multiple times for duplicates) + var rulesToRemove = new[] + { + PortRuleUdp16000, + PortRuleUdp16001, + PortRuleTcp16001, + GeneralsRule, + GeneralsGameDatRule, + ZeroHourRule, + ZeroHourGameDatRule, + }; + + foreach (var ruleName in rulesToRemove) + { + // Remove multiple times in case of duplicates (like GenPatcher) + for (int i = 0; i < 3; i++) + { + RemoveFirewallRule(ruleName); + } + + details.Add($"✓ Removed rule: {ruleName}"); + } + }, + cancellationToken); + + _logger.LogInformation("Firewall rules removed"); + 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 bool IsFirewallRuleExists(string ruleName) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "netsh.exe", + 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.WaitForExit(); + + // GenPatcher checks: if output contains "No rules", rule doesn't exist + return !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) + { + try + { + // GenPatcher command: netsh advfirewall firewall add rule name="GP Open UDP Port 16000" dir=in action=allow edge=yes protocol=UDP localport=16000 + var psi = new ProcessStartInfo + { + FileName = "netsh.exe", + Arguments = $"advfirewall firewall add rule name=\"{ruleName}\" dir=in action=allow edge=yes protocol={protocol} localport={port}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + _logger.LogInformation("Running: netsh {Args}", psi.Arguments); + + using var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(); + return process.ExitCode == 0; + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding port firewall rule: {RuleName}", ruleName); + return false; + } + } + + private bool AddProgramRule(string ruleName, string programPath) + { + try + { + // GenPatcher command: netsh advfirewall firewall add rule name="GP Command & Conquer Generals" dir=in action=allow edge=yes program="..." enable=yes + var psi = new ProcessStartInfo + { + FileName = "netsh.exe", + Arguments = $"advfirewall firewall add rule name=\"{ruleName}\" dir=in action=allow edge=yes program=\"{programPath}\" enable=yes", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + _logger.LogInformation("Running: netsh {Args}", psi.Arguments); + + using var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(); + return process.ExitCode == 0; + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding program firewall rule: {RuleName}", ruleName); + return false; + } + } + + private bool RemoveFirewallRule(string ruleName) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "netsh.exe", + Arguments = $"advfirewall firewall delete rule name=\"{ruleName}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(); + return process.ExitCode == 0; + } + + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error removing firewall rule: {RuleName}", 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..e674ee631 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GameRangerRunAsAdmin.cs @@ -0,0 +1,216 @@ +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 string[] GeneralsExecutables = ["Generals.exe", "generals.exe"]; + private static readonly string[] ZeroHourExecutables = ["game.exe", "Game.exe"]; + private readonly ILogger _logger = logger; + + /// + public override string Id => "GameRangerRunAsAdmin"; + + /// + public override string Title => "GameRanger Run as Administrator"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Only applicable if GameRanger IS installed + var gameRangerInstalled = IsGameRangerInstalled(); + return Task.FromResult(gameRangerInstalled && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + 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 cancellationToken) + { + try + { + var gameRangerInstalled = IsGameRangerInstalled(); + + if (!gameRangerInstalled) + { + _logger.LogInformation("GameRanger is not installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Check if admin compatibility is already set + if (HasAdminCompatibility(installation)) + { + _logger.LogInformation("Game executables already have run as administrator compatibility."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Provide guidance for GameRanger + _logger.LogWarning("GameRanger is installed. Games should run as administrator for GameRanger compatibility."); + _logger.LogInformation("To configure GameRanger:"); + _logger.LogInformation("1. Open GameRanger"); + _logger.LogInformation("2. Go to 'Edit' > 'Game Settings'"); + _logger.LogInformation("3. Select Generals or Zero Hour"); + _logger.LogInformation("4. Check 'Run this program as an administrator' option"); + _logger.LogInformation("5. Ensure it is enabled"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, you can:"); + _logger.LogInformation("- Right-click on game executable"); + _logger.LogInformation("- Select 'Properties'"); + _logger.LogInformation("- Go to 'Compatibility' tab"); + _logger.LogInformation("- Check 'Run this program as an administrator'"); + _logger.LogInformation("- Click 'Apply' and 'OK'"); + _logger.LogInformation("Alternatively, you can:"); + _logger.LogInformation("- Configure Windows to always run games as administrator"); + _logger.LogInformation("- Use compatibility mode if available"); + + return Task.FromResult(new ActionSetResult(true, "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 cancellationToken) + { + _logger.LogWarning("GameRanger Run as Administrator Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool IsGameRangerInstalled() + { + try + { + // Check for GameRanger in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + false); + + if (key != null) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey != null) + { + if (subKey.GetValue("DisplayName") is string displayName && displayName.Contains("GameRanger", StringComparison.OrdinalIgnoreCase)) + { + 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 = new List(); + + if (installation.HasGenerals) + { + executables.AddRange(GeneralsExecutables); + } + + if (installation.HasZeroHour) + { + executables.AddRange(ZeroHourExecutables); + } + + foreach (var exe in executables) + { + var exePath = exe.Equals("game.exe", StringComparison.OrdinalIgnoreCase) || exe.Equals("Game.exe", StringComparison.OrdinalIgnoreCase) + ? Path.Combine(installation.ZeroHourPath, exe) + : Path.Combine(installation.GeneralsPath, exe); + + if (!File.Exists(exePath)) + { + continue; + } + + // Check for compatibility flags in AppCompat registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers", + false); + + if (key != null) + { + if (key.GetValue(exePath) is string flags && flags.Contains("RUNASADMIN", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + 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..692e4924f --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenArial.cs @@ -0,0 +1,155 @@ +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 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 readonly ILogger _logger = logger; + 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 bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + if (File.Exists(_markerPath)) return Task.FromResult(true); + return Task.FromResult(IsArialFontInstalled()); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + 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."); + _logger.LogInformation("Arial font is typically included with Windows."); + _logger.LogInformation("To install Arial font:"); + _logger.LogInformation("1. Open Windows Settings"); + _logger.LogInformation("2. Go to 'Apps' > 'Optional features'"); + _logger.LogInformation("3. Click 'View features' next to 'Add a font'"); + _logger.LogInformation("4. Click 'Get more fonts in Microsoft Store'"); + _logger.LogInformation("5. Search for 'Arial' and install"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, you can:"); + _logger.LogInformation("- Copy Arial font files from another Windows computer"); + _logger.LogInformation("- Download Arial font from a trusted source"); + _logger.LogInformation("- Install the font by right-clicking and selecting 'Install for all users'"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create marker file."); + } + + return Task.FromResult(new ActionSetResult(true, "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 cancellationToken) + { + _logger.LogWarning("GenArial Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool IsArialFontInstalled() + { + try + { + // Check for Arial font in Windows fonts directory + var fontsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "Fonts"); + + var arialFiles = new[] + { + "arial.ttf", + "arialbd.ttf", + "ariali.ttf", + "arialbi.ttf", + "ARIAL.TTF", + }; + + foreach (var fontFile in arialFiles) + { + if (File.Exists(Path.Combine(fontsPath, fontFile))) + { + _logger.LogInformation("Found Arial font: {Font}", fontFile); + return true; + } + } + + // Check for Arial in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", + false); + + if (key != null) + { + foreach (var valueName in key.GetValueNames()) + { + if (valueName.Contains("Arial", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Found Arial font in registry: {Font}", valueName); + 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..01c59bf59 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/GenToolFix.cs @@ -0,0 +1,171 @@ +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.Models.GameInstallations; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +/// +/// 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) +{ + /// + public override string Id => "GenToolFix"; + + /// + public override string Title => "GenTool"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; // Recommended but not strictly crucial for launch (though highly recommended) + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + bool appliedGenerals = !installation.HasGenerals || File.Exists(Path.Combine(installation.GeneralsPath, "d3d8.dll")); + bool appliedZeroHour = !installation.HasZeroHour || File.Exists(Path.Combine(installation.ZeroHourPath, "d3d8.dll")); + return Task.FromResult(appliedGenerals && appliedZeroHour); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var tempFile = Path.Combine(Path.GetTempPath(), "gentool_setup.zip"); + var details = new List(); + + try + { + details.Add("Downloading GenTool..."); + + using var client = httpClientFactory.CreateClient("Downloader"); + + // Add User-Agent to avoid blocking + 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 }; + bool downloaded = false; + + foreach (var url in urls) + { + try + { + logger.LogInformation("Attempting GenTool download from {Url}", url); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fileSize = response.Content.Headers.ContentLength ?? 0; + + // GenTool zip is small but definitely > 100KB + if (fileSize < 1024 * 100) + { + logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Likely blocked.", url, fileSize); + continue; + } + + details.Add($"✓ Downloaded {fileSize / 1024.0:F2} KB from {new Uri(url).Host}"); + + using var fs = new FileStream(tempFile, FileMode.Create); + await response.Content.CopyToAsync(fs, cancellationToken); + fs.Close(); + downloaded = true; + break; + } + catch (Exception ex) + { + logger.LogWarning("Failed to download from {Url}: {Error}", url, ex.Message); + } + } + + if (!downloaded) + { + return new ActionSetResult(false, "Failed to download GenTool from all mirrors.", details); + } + + details.Add("Extracting GenTool..."); + + // Extract d3d8.dll from zip + bool dllFound = false; + using (var archive = ZipFile.OpenRead(tempFile)) + { + foreach (var entry in archive.Entries) + { + if (entry.Name.Equals("d3d8.dll", StringComparison.OrdinalIgnoreCase)) + { + dllFound = true; + + // Extract to Generals path if valid + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var dest = Path.Combine(installation.GeneralsPath, "d3d8.dll"); + entry.ExtractToFile(dest, true); + details.Add($"✓ Installed GenTool to Generals: {dest}"); + } + + // Extract to Zero Hour path if valid + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var dest = Path.Combine(installation.ZeroHourPath, "d3d8.dll"); + entry.ExtractToFile(dest, true); + details.Add($"✓ Installed GenTool to Zero Hour: {dest}"); + } + + break; + } + } + } + + if (!dllFound) + { + return new ActionSetResult(false, "d3d8.dll not found in downloaded archive.", details); + } + + File.Delete(tempFile); + + // Add Defender exclusions (would require admin, currently just logging) + details.Add("ℹ Note: You may need to add 'd3d8.dll' to Windows Defender exclusions manually."); + + return new ActionSetResult(true, "GenTool installed successfully.", details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to apply GenTool fix"); + return new ActionSetResult(false, $"Error: {ex.Message}", details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath)) + { + var p = Path.Combine(installation.GeneralsPath, "d3d8.dll"); + if (File.Exists(p)) File.Delete(p); + } + + if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath)) + { + var p = Path.Combine(installation.ZeroHourPath, "d3d8.dll"); + if (File.Exists(p)) File.Delete(p); + } + + return Task.FromResult(new ActionSetResult(true, "GenTool removed.")); + } +} 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..ced6e17fd --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/HDIconsFix.cs @@ -0,0 +1,158 @@ +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.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides high-definition icons for Generals and Zero Hour. +/// This fix replaces low-resolution game icons with HD versions. +/// +public class HDIconsFix(ILogger logger) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", "sub_markers", "HDIconsFix.done"); + + /// + public override string Id => "HDIconsFix"; + + /// + public override string Title => "High-Definition Icons"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + if (File.Exists(_markerPath)) return Task.FromResult(true); + return Task.FromResult(AreHDIconsPresent(installation)); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + details.Add("High-Definition Icons - Informational"); + details.Add(string.Empty); + details.Add("⚠ NOTE: HD Icons are provided by mods or community content"); + details.Add(" GenHub's Content system handles icon downloads"); + details.Add(string.Empty); + details.Add("To get HD Icons:"); + details.Add(" 1. Open GenHub"); + details.Add(" 2. Go to Downloads section"); + details.Add(" 3. Browse 'Icons' category"); + details.Add(" 4. Download and install HD icon packs"); + details.Add(string.Empty); + + // Check current status + var hdIconsPresent = AreHDIconsPresent(installation); + if (hdIconsPresent) + { + details.Add("✓ HD icons are already installed"); + } + else + { + details.Add("⚠ No HD icons found"); + details.Add(" Use GenHub's Content system to download icon packs"); + } + + _logger.LogInformation("HD Icons are typically provided by mods or community content."); + _logger.LogInformation("Use GenHub's Content system to download HD icon packs."); + _logger.LogInformation("HD Icons can be found in the Downloads section under 'Icons' category."); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create marker file for HDIconsFix"); + } + + return Task.FromResult(new ActionSetResult(true, "HD Icons are available through GenHub's Content system.", details)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying HD icons fix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("HD Icons Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool AreHDIconsPresent(GameInstallation installation) + { + try + { + // Check for HD icon files in game directories + var hdIconFiles = new[] + { + "generals.ico", + "game.ico", + "zh.ico", + "generals_hd.ico", + "game_hd.ico", + }; + + var foundHDIcons = false; + + if (installation.HasGenerals) + { + foreach (var iconFile in hdIconFiles) + { + if (File.Exists(Path.Combine(installation.GeneralsPath, iconFile))) + { + _logger.LogInformation("Found HD icon: {Icon}", iconFile); + foundHDIcons = true; + break; + } + } + } + + if (installation.HasZeroHour && !foundHDIcons) + { + foreach (var iconFile in hdIconFiles) + { + if (File.Exists(Path.Combine(installation.ZeroHourPath, iconFile))) + { + _logger.LogInformation("Found HD icon: {Icon}", iconFile); + foundHDIcons = true; + break; + } + } + } + + return foundHDIcons; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error checking for HD icons"); + return false; + } + } +} 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..726614466 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/IntelGfxDriverCompatibility.cs @@ -0,0 +1,204 @@ +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 ILogger _logger = 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 bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Only applicable if Intel graphics are present + var hasIntelGfx = HasIntelGraphics(); + return Task.FromResult(hasIntelGfx && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + 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 (File.Exists(_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 cancellationToken) + { + try + { + var hasIntelGfx = HasIntelGraphics(); + + if (!hasIntelGfx) + { + _logger.LogInformation("Intel graphics not detected. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Check if driver is up to date + if (IsIntelDriverUpToDate()) + { + _logger.LogInformation("Intel graphics driver is up to date. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Provide guidance for Intel graphics driver + _logger.LogWarning("Intel graphics driver detected. May need update for best compatibility."); + _logger.LogInformation("To update Intel graphics driver:"); + _logger.LogInformation("1. Open Intel Driver & Support Assistant"); + _logger.LogInformation("2. Go to 'Drivers' tab"); + _logger.LogInformation("3. Click 'Check for updates'"); + _logger.LogInformation("4. Follow prompts to install latest driver"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, download from Intel website:"); + _logger.LogInformation("{Url}", ExternalUrls.IntelDriverDownloadUrl); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Note: After updating driver, you may need to:"); + _logger.LogInformation("- Restart your computer"); + _logger.LogInformation("- Run GenHub fixes again"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create marker file for IntelGfxDriverCompatibility"); + } + + _logger.LogInformation("- Test game performance"); + + return Task.FromResult(new ActionSetResult(true, "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 cancellationToken) + { + _logger.LogWarning("Intel Graphics Driver Compatibility Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool HasIntelGraphics() + { + try + { + // Check for Intel graphics in system + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + $@"{RegistryConstants.IntelGraphicsClassKeyPath}\0000", + false); + + if (key != null) + { + 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("root\\CIMV2", "SELECT * FROM Win32_VideoController"); + using var results = searcher.Get(); + + foreach (ManagementBaseObject result in results) + { + 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 != null) + { + 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..a6bb5a3fa --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MalwarebytesFix.cs @@ -0,0 +1,142 @@ +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 ILogger _logger = logger; + + /// + public override string Id => "MalwarebytesFix"; + + /// + public override string Title => "Malwarebytes Compatibility"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + // This is an informational fix - always returns false since it requires manual action + // Users must manually add exclusions to Malwarebytes + return Task.FromResult(false); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + 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)); + } + + // Provide guidance for adding exclusions + 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 the following folders to Malwarebytes exclusions:"); + foreach (var path in paths) + { + _logger.LogWarning(" - {Path}", path); + } + + _logger.LogInformation("To add exclusions in Malwarebytes:"); + _logger.LogInformation("1. Open Malwarebytes"); + _logger.LogInformation("2. Go to Settings > Exclusions"); + _logger.LogInformation("3. Click 'Add Folder' and select the game folders listed above"); + _logger.LogInformation("4. Click 'Done' to save changes"); + + return Task.FromResult(new ActionSetResult(true, "Please manually add game folders to Malwarebytes exclusions. See details for instructions.", 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 cancellationToken) + { + _logger.LogWarning("Malwarebytes Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + 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..3c2461dbb --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/MyDocumentsPathCompatibility.cs @@ -0,0 +1,110 @@ +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.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) +{ + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", "sub_markers", "MyDocumentsPathCompatibility.done"); + + /// + public override string Id => "MyDocumentsPathCompatibility"; + + /// + public override string Title => "My Documents Path Compatibility"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + string documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + + if (File.Exists(_markerPath)) return Task.FromResult(true); + + // 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) + { + // We cannot automatically move the Documents folder as it requires user interaction/OS configuration. + // We return a failure with a descriptive message to prompt the user. + // In the future, we might implement a symlink workaround similar to OneDriveFix here too, + // but for now, we flag it. + string documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + + // Since we can't auto-fix, if the user clicked Apply, we assume they saw the message. + // We mark it as applied so it doesn't stay blue/red forever. + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create marker file for MyDocumentsPathCompatibility"); + } + + // We still return failure message to warn them, but next time it will be Green. + // Actually, if we return Failure, the UI might show Red X. + // But IsApplied will be true next check. + return Task.FromResult(Failure($"Your 'Documents' path '{documentsPath}' contains incomplete characters. Please move your Documents folder manually. Marked as acknowledged.")); + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct) + { + return Task.FromResult(Success()); + } + + 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..ecc7dbce3 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NahimicFix.cs @@ -0,0 +1,154 @@ +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 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) +{ + private readonly ILogger _logger = logger; + + /// + public override string Id => "NahimicFix"; + + /// + public override string Title => "Nahimic Audio Compatibility"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + // 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 cancellationToken) + { + 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)); + } + + // Provide guidance for disabling Nahimic + 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."); + _logger.LogInformation("To disable Nahimic audio effects:"); + _logger.LogInformation("1. Open Task Manager (Ctrl+Shift+Esc)"); + _logger.LogInformation("2. Go to the 'Services' tab"); + _logger.LogInformation("3. Find 'Nahimic Service' or 'Nahimic Service UI'"); + _logger.LogInformation("4. Right-click and select 'Stop'"); + _logger.LogInformation("5. Right-click again and select 'Properties'"); + _logger.LogInformation("6. Change 'Startup type' to 'Disabled'"); + _logger.LogInformation("7. Click 'Apply' and 'OK'"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, you can uninstall Nahimic audio software if you don't need it."); + + return Task.FromResult(new ActionSetResult(true, "Please manually disable Nahimic service. See details for instructions.", 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 cancellationToken) + { + _logger.LogWarning("Nahimic Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static bool IsNahimicInstalled() + { + try + { + // Check for Nahimic in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + RegistryConstants.UninstallKeyPath, + false); + + if (key != null) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey != null) + { + if (subKey.GetValue("DisplayName") is string displayName && displayName.Contains("Nahimic", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + // Check for Nahimic processes + var processes = Process.GetProcessesByName("Nahimic"); + if (processes.Length > 0) + { + return true; + } + + processes = Process.GetProcessesByName("NahimicService"); + return processes.Length > 0; + } + catch (Exception) + { + return false; + } + } +} 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..97ecb9fa4 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/NetworkPrivateProfileFix.cs @@ -0,0 +1,177 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management; +using System.Threading; +using System.Threading.Tasks; +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 readonly ILogger _logger = logger; + + /// + public override string Id => "NetworkPrivateProfileFix"; + + /// + public override string Title => "Network Private Profile"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check if at least one network adapter is set to Private + var profiles = GetNetworkProfiles(); + var hasPrivate = profiles.Any(p => p.Equals("Private", StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(hasPrivate); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking network profile status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + var profiles = GetNetworkProfiles(); + details.Add($"Found {profiles.Count} network adapter(s)"); + + foreach (var profile in profiles) + { + details.Add($"• Adapter profile: {profile}"); + } + + if (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..."); + + // Use PowerShell to set network profile - run asynchronously to avoid blocking UI + var success = await Task.Run( + () => + { + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-WindowStyle Hidden -NonInteractive -Command \"Set-NetConnectionProfile -NetworkCategory Private\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + process.WaitForExit(); + return process.ExitCode == 0; + } + + return false; + }, + cancellationToken); + + 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); + } + else + { + 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 Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Network Private Profile Fix cannot be easily undone. Network profile must be manually changed through Windows Settings."); + return Task.FromResult(new ActionSetResult(true, null, ["To undo, manually change network profile in Windows Settings > Network & Internet > Network and Sharing Center"])); + } + + private List GetNetworkProfiles() + { + var profiles = new List(); + + try + { + // Use PowerShell to get network profiles + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-WindowStyle Hidden -NonInteractive -Command \"Get-NetConnectionProfile | Select-Object -ExpandProperty NetworkCategory\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi); + if (process != null) + { + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Split by newlines and trim each line + var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + 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..ad5028d7d --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OneDriveFix.cs @@ -0,0 +1,272 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +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 prevents OneDrive from syncing game folders. +/// This fix creates desktop.ini files with ThisPCPolicy=DisableCloudSync +/// to prevent OneDrive from syncing game installation and user data folders. +/// +public class OneDriveFix(ILogger logger) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + + private readonly string[] _commonFolderNames = + [ + "Command and Conquer Generals Data", + "Command and Conquer Generals Zero Hour Data", + "Command & Conquer Generäle Stunde Null Data", + "Command & Conquer Generals - Heure H Data" + ]; + + /// + public override string Id => "OneDriveFix"; + + /// + public override string Title => "Prevent OneDrive Sync (Move & Symlink)"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Fix is only applicable if Documents is redirected to OneDrive + return Task.FromResult(IsOneDriveRedirected() && (installation.HasGenerals || installation.HasZeroHour)); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // If not redirected, not applicable. Return false so it shows as NOT APPLICABLE instead of APPLIED + if (!IsOneDriveRedirected()) return Task.FromResult(false); + + foreach (var folderName in _commonFolderNames) + { + if (!IsFolderCorrectlySymlinked(folderName)) + { + return Task.FromResult(false); + } + } + + return Task.FromResult(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking OneDrive protection status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + 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 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}"); + } + + int foldersProcessed = 0; + foreach (var folderName in _commonFolderNames) + { + var cloudPath = Path.Combine(cloudDocs, folderName); + var localPath = Path.Combine(localDocs, folderName); + + if (!Directory.Exists(cloudPath) && !Directory.Exists(localPath)) continue; + + if (IsFolderCorrectlySymlinked(folderName)) + { + details.Add($"✓ Folder '{folderName}' is already correctly symlinked."); + continue; + } + + // Handle merge scenario: If both exist and cloud is not a symlink + if (Directory.Exists(cloudPath) && !IsSymbolicLink(cloudPath) && Directory.Exists(localPath)) + { + details.Add($"⚠ Both cloud and local versions of '{folderName}' exist."); + details.Add(" Attempting to merge cloud files into local folder..."); + try + { + MergeDirectories(cloudPath, localPath); + Directory.Delete(cloudPath, true); + details.Add(" ✓ Cloud folder contents merged and original removed."); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to merge {Cloud} into {Local}", cloudPath, localPath); + details.Add($" ⚠ Failed to fully merge: {ex.Message}"); + + // Rename cloud folder to avoid conflict for symlink creation + var bakPath = cloudPath + ".bak_" + DateTime.Now.Ticks; + Directory.Move(cloudPath, bakPath); + details.Add($" ✓ Cloud folder renamed to: {Path.GetFileName(bakPath)}"); + } + } + + // If folder exists in cloud but not local, move it + if (Directory.Exists(cloudPath) && !IsSymbolicLink(cloudPath) && !Directory.Exists(localPath)) + { + details.Add($"Moving '{folderName}' from OneDrive to local Documents..."); + Directory.Move(cloudPath, localPath); + details.Add($" ✓ Moved to: {localPath}"); + } + + // Create symlink + if (Directory.Exists(localPath) && !Directory.Exists(cloudPath)) + { + details.Add($"Creating symlink in OneDrive for '{folderName}'..."); + Directory.CreateSymbolicLink(cloudPath, localPath); + details.Add($" ✓ Symlink created: {cloudPath} -> {localPath}"); + } + + // Apply Pin attribute to local folder + await ApplyPinAttributeAsync(localPath, cancellationToken); + foldersProcessed++; + } + + details.Add(string.Empty); + details.Add($"✓ Processed {foldersProcessed} folders for OneDrive compatibility"); + details.Add("✓ OneDrive relocation completed successfully"); + + return new ActionSetResult(true, null, details); + } + 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 cancellationToken) + { + _logger.LogWarning("Undoing OneDrive folder relocation is not supported automatically."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static void MergeDirectories(string source, string target) + { + foreach (var dirPath in Directory.GetDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(dirPath.Replace(source, target)); + } + + foreach (var newPath in Directory.GetFiles(source, "*.*", SearchOption.AllDirectories)) + { + var targetFile = newPath.Replace(source, target); + if (!File.Exists(targetFile)) + { + File.Move(newPath, targetFile); + } + } + } + + 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 neither exist, we consider it "fine" (it will be fixed when they appear) + if (!Directory.Exists(cloudPath) && !Directory.Exists(localPath)) return true; + + // If local exists and cloud is a symlink to it, it's applied + if (Directory.Exists(localPath) && IsSymbolicLink(cloudPath)) + { + // We could check the target here, but Directory.Exists(localPath) + IsSymbolicLink(cloudPath) is 99% there. + return true; + } + + // If cloud exists as real folder but local doesn't, it's NOT applied + if (Directory.Exists(cloudPath) && !IsSymbolicLink(cloudPath)) return false; + + return false; + } + + private async Task ApplyPinAttributeAsync(string path, CancellationToken ct) + { + try + { + if (!Directory.Exists(path)) return; + + // Use PowerShell to apply 'Pinned' attribute which is specific to modern Windows / OneDrive + // Attrib +P -U + var psi = new ProcessStartInfo + { + FileName = "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 (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..390d6eea6 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/OptionsINIFix.cs @@ -0,0 +1,344 @@ +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; + +namespace GenHub.Windows.Features.ActionSets.Fixes; + +/// +/// Fix that applies optimal settings to the Options.ini file for Generals and Zero Hour. +/// +public class OptionsINIFix(IGameSettingsService gameSettingsService, ILogger logger) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + + /// + public override string Id => "OptionsINIFix"; + + /// + public override string Title => "Options.ini Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // This fix is applicable for both Generals and Zero Hour + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override async Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Determine which game type to check + GameType gameType; + if (installation.HasZeroHour) + { + gameType = GameType.ZeroHour; + } + else if (installation.HasGenerals) + { + gameType = GameType.Generals; + } + else + { + return false; + } + + var optionsFilePath = gameSettingsService.GetOptionsFilePath(gameType); + + if (!File.Exists(optionsFilePath)) + { + return false; + } + + var loadResult = await gameSettingsService.LoadOptionsAsync(gameType); + if (!loadResult.Success || loadResult.Data == null) + { + return false; + } + + var options = loadResult.Data; + + // Check if all required settings are present with correct values + if (!IsOptionsValid(options)) + { + return false; + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Options.ini status"); + return false; + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + details.Add("Starting Options.ini optimization..."); + + // Determine which game type to apply to + GameType gameType; + if (installation.HasZeroHour) + { + gameType = GameType.ZeroHour; + details.Add("Target game: Command & Conquer: Generals Zero Hour"); + } + else if (installation.HasGenerals) + { + gameType = GameType.Generals; + details.Add("Target game: Command & Conquer: Generals"); + } + else + { + details.Add("✗ No game installation found"); + return new ActionSetResult(false, "No game installation found", details); + } + + var optionsPath = gameSettingsService.GetOptionsFilePath(gameType); + details.Add($"Options.ini path: {optionsPath}"); + + details.Add("Loading Options.ini..."); + var loadResult = await gameSettingsService.LoadOptionsAsync(gameType); + if (!loadResult.Success || loadResult.Data == null) + { + details.Add($"✗ Failed to load Options.ini"); + if (loadResult.Errors != null && loadResult.Errors.Any()) + { + foreach (var error in loadResult.Errors) + { + details.Add($" • {error}"); + } + } + + return new ActionSetResult(false, $"Failed to load Options.ini: {string.Join(", ", loadResult.Errors ?? [])}", details); + } + + details.Add("✓ Options.ini loaded successfully"); + var options = loadResult.Data; + + // Check current resolution + var currentRes = $"{options.Video.ResolutionWidth}x{options.Video.ResolutionHeight}"; + details.Add($"Current resolution: {currentRes}"); + + var resolutionChanged = false; + if (IsBadResolution(options.Video.ResolutionWidth, options.Video.ResolutionHeight)) + { + details.Add(" ⚠ Bad resolution detected, will be changed to 1920x1080"); + options.Video.ResolutionWidth = 1920; + options.Video.ResolutionHeight = 1080; + resolutionChanged = true; + } + + details.Add("Applying optimal settings..."); + + // Apply optimal settings + ApplyOptimalSettings(options, details); + + // Log what was changed + details.Add("✓ Video settings optimized:"); + details.Add(" • AntiAliasing = 1"); + details.Add(" • TextureReduction = 0"); + details.Add(" • ExtraAnimations = yes"); + details.Add(" • Gamma = 50"); + details.Add(" • UseShadowDecals = yes"); + details.Add(" • UseShadowVolumes = no"); + details.Add(" • Windowed = no"); + + if (resolutionChanged) + { + details.Add($" • Resolution = 1920x1080 (changed from {currentRes})"); + } + + details.Add("✓ Audio settings optimized:"); + details.Add(" • SFXVolume = 70"); + details.Add(" • SFX3DVolume = 70"); + details.Add(" • MusicVolume = 70"); + details.Add(" • VoiceVolume = 70"); + details.Add(" • NumSounds = 16"); + + details.Add("✓ Network settings optimized:"); + details.Add(" • GameSpyIPAddress = 0.0.0.0"); + + details.Add("✓ TheSuperHackers settings optimized:"); + details.Add(" • DynamicLOD = no"); + details.Add(" • HeatEffects = no"); + details.Add(" • MaxParticleCount = 1000"); + details.Add(" • SendDelay = no"); + details.Add(" • ShowSoftWaterEdge = yes"); + details.Add(" • ShowTrees = yes"); + details.Add(" • UseAlternateMouse = no"); + details.Add(" • UseDoubleClickAttackMove = no"); + + details.Add("Saving optimized Options.ini..."); + var saveResult = await gameSettingsService.SaveOptionsAsync(gameType, options); + if (!saveResult.Success) + { + details.Add($"✗ Failed to save Options.ini"); + if (saveResult.Errors != null && saveResult.Errors.Any()) + { + foreach (var error in saveResult.Errors) + { + details.Add($" • {error}"); + } + } + + return new ActionSetResult(false, $"Failed to save Options.ini: {string.Join(", ", saveResult.Errors ?? [])}", details); + } + + details.Add($"✓ Saved to: {optionsPath}"); + details.Add("✓ Options.ini optimization completed successfully"); + + _logger.LogInformation("Options.ini fix applied successfully for {GameType} with {Count} actions", gameType, 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 cancellationToken) + { + _logger.LogWarning("Undoing Options.ini fix is not supported via GenHub."); + return Task.FromResult(Success()); + } + + private static bool IsOptionsValid(IniOptions options) + { + // Check core video settings + if (options.Video.ExtraAnimations != true) return false; + if (options.Video.Gamma != 50) return false; + if (options.Video.TextureReduction != 0) return false; + if (options.Video.AntiAliasing != 1) return false; + if (options.Video.UseShadowDecals != true) return false; + if (options.Video.UseShadowVolumes != false) return false; + + // Check audio settings + if (options.Audio.SFXVolume != 70) return false; + if (options.Audio.SFX3DVolume != 70) return false; + if (options.Audio.MusicVolume != 70) return false; + if (options.Audio.VoiceVolume != 70) return false; + + // Check bad resolutions + if (IsBadResolution(options.Video.ResolutionWidth, options.Video.ResolutionHeight)) + return false; + + // Check [TheSuperHackers] section + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tsh)) + { + return false; + } + + // Validate essential TSH settings that GenPatcher looks for + if (tsh.GetValueOrDefault("DynamicLOD") != GameSettingsConstants.OptimalSettings.DynamicLOD) return false; + if (tsh.GetValueOrDefault("MaxParticleCount") != GameSettingsConstants.OptimalSettings.MaxParticleCount) return false; + if (tsh.GetValueOrDefault("HeatEffects") != GameSettingsConstants.OptimalSettings.HeatEffects) return false; + if (tsh.GetValueOrDefault("SendDelay") != GameSettingsConstants.OptimalSettings.SendDelay) return false; + if (tsh.GetValueOrDefault("ShowSoftWaterEdge") != GameSettingsConstants.OptimalSettings.ShowSoftWaterEdge) return false; + if (tsh.GetValueOrDefault("ShowTrees") != GameSettingsConstants.OptimalSettings.ShowTrees) return false; + if (tsh.GetValueOrDefault("UseAlternateMouse") != GameSettingsConstants.OptimalSettings.UseAlternateMouse) return false; + if (tsh.GetValueOrDefault("UseDoubleClickAttackMove") != GameSettingsConstants.OptimalSettings.UseDoubleClickAttackMove) return false; + if (tsh.GetValueOrDefault("BuildingOcclusion") != GameSettingsConstants.OptimalSettings.BuildingOcclusion) return false; + if (tsh.GetValueOrDefault("Retaliation") != GameSettingsConstants.OptimalSettings.Retaliation) return false; + if (tsh.GetValueOrDefault("UseCloudMap") != GameSettingsConstants.OptimalSettings.UseCloudMap) return false; + if (tsh.GetValueOrDefault("UseLightMap") != GameSettingsConstants.OptimalSettings.UseLightMap) return false; + + return true; + } + + private static void ApplyOptimalSettings(IniOptions options, List details) + { + options.Video.AntiAliasing = GameSettingsConstants.OptimalSettings.AntiAliasing; + options.Video.TextureReduction = GameSettingsConstants.OptimalSettings.TextureReduction; + options.Video.ExtraAnimations = GameSettingsConstants.OptimalSettings.ExtraAnimations; + options.Video.Gamma = GameSettingsConstants.OptimalSettings.Gamma; + options.Video.UseShadowDecals = GameSettingsConstants.OptimalSettings.UseShadowDecals; + options.Video.UseShadowVolumes = GameSettingsConstants.OptimalSettings.UseShadowVolumes; + options.Video.Windowed = GameSettingsConstants.OptimalSettings.Windowed; + + details.Add($"✓ Set AntiAliasing = {GameSettingsConstants.OptimalSettings.AntiAliasing}"); + details.Add($"✓ Set TextureReduction = {GameSettingsConstants.OptimalSettings.TextureReduction}"); + details.Add($"✓ Set Gamma = {GameSettingsConstants.OptimalSettings.Gamma}"); + + 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; + + // Resolution handling is now done in ApplyInternalAsync to better track changes. + + // Set network settings + options.Network.GameSpyIPAddress = GameSettingsConstants.OptimalSettings.GameSpyIPAddress; + + // Ensure [TheSuperHackers] section exists with optimal defaults + if (!options.AdditionalSections.TryGetValue(ActionSetConstants.IniFiles.TheSuperHackersSection, out var tsh)) + { + tsh = []; + options.AdditionalSections[ActionSetConstants.IniFiles.TheSuperHackersSection] = tsh; + } + + tsh["BuildingOcclusion"] = GameSettingsConstants.OptimalSettings.BuildingOcclusion; + tsh["CampaignDifficulty"] = GameSettingsConstants.OptimalSettings.CampaignDifficulty; + tsh["DynamicLOD"] = GameSettingsConstants.OptimalSettings.DynamicLOD; + tsh["FirewallPortOverride"] = GameSettingsConstants.OptimalSettings.FirewallPortOverride; + tsh["HeatEffects"] = GameSettingsConstants.OptimalSettings.HeatEffects; + tsh["IdealStaticGameLOD"] = GameSettingsConstants.OptimalSettings.IdealStaticGameLOD; + tsh["LanguageFilter"] = GameSettingsConstants.OptimalSettings.LanguageFilter; + tsh["MaxParticleCount"] = GameSettingsConstants.OptimalSettings.MaxParticleCount; + tsh["Retaliation"] = GameSettingsConstants.OptimalSettings.Retaliation; + tsh["ScrollFactor"] = GameSettingsConstants.OptimalSettings.ScrollFactor; + tsh["SendDelay"] = GameSettingsConstants.OptimalSettings.SendDelay; + tsh["ShowSoftWaterEdge"] = GameSettingsConstants.OptimalSettings.ShowSoftWaterEdge; + tsh["ShowTrees"] = GameSettingsConstants.OptimalSettings.ShowTrees; + tsh["StaticGameLOD"] = GameSettingsConstants.OptimalSettings.StaticGameLOD; + tsh["UseAlternateMouse"] = GameSettingsConstants.OptimalSettings.UseAlternateMouse; + tsh["UseCloudMap"] = GameSettingsConstants.OptimalSettings.UseCloudMap; + tsh["UseDoubleClickAttackMove"] = GameSettingsConstants.OptimalSettings.UseDoubleClickAttackMove; + tsh["UseLightMap"] = GameSettingsConstants.OptimalSettings.UseLightMap; + + details.Add("✓ Applied optimal GenPatcher settings/compatibility tweaks"); + } + + private static bool IsBadResolution(int width, int height) + { + return (width == 800 && height == 600) || + (width == 1024 && height == 768) || + (width == 1280 && height == 1024) || + (width == 1600 && height == 1200) || + (width == 1280 && height == 720) || + (width == 1360 && height == 768) || + (width == 1366 && height == 768) || + (width == 1600 && height == 900); + } + + private static new ActionSetResult Success() => new(true); +} 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..aa8a690a0 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch104Fix.cs @@ -0,0 +1,281 @@ +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.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +namespace GenHub.Windows.Features.ActionSets.Fixes; + +/// +/// Installs the Zero Hour 1.04 official patch. +/// +/// The HTTP client factory. +/// The logger instance. +public class Patch104Fix(IHttpClientFactory httpClientFactory, ILogger logger) : BaseActionSet(logger) +{ + /// + /// Gets the description of the fix. + /// + public static string Description => "Official Zero Hour 1.04 patch - required for multiplayer and compatibility."; + + /// + public override string Id => "Patch104"; + + /// + public override string Title => "Zero Hour 1.04 Patch"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; // Download failures shouldn't abort entire sequence + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Disabled per user request - redundant with GenHub Downloads section + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check if game.exe version is 1.04 + var gameExePath = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameExe); + if (!File.Exists(gameExePath)) + { + return Task.FromResult(false); + } + + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + // 1.04 version should be 1.4.0.0 or similar + if (version != null && version.StartsWith("1.4")) + { + 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 cancellationToken) + { + var details = new List(); + + var isExe = false; + 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}"); + + details.Add("Downloading patch..."); + + using var client = httpClientFactory.CreateClient("Downloader"); + + // Add User-Agent to avoid blocking + 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); // Increase timeout for large downloads + + var urls = new[] { ExternalUrls.ZeroHour104PatchUrlPrimary, ExternalUrls.ZeroHour104PatchUrlMirror1 }; + bool downloaded = false; + + foreach (var url in urls) + { + try + { + logger.LogInformation("Attempting download from {Url}", url); + + var uri = new Uri(url); + isExe = uri.AbsolutePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + + // Update temp path based on extension + downloadPath = isExe + ? Path.Combine(Path.GetTempPath(), "GeneralsZH-104-english.exe") + : Path.Combine(Path.GetTempPath(), "zh104_patch.zip"); + + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fileSize = response.Content.Headers.ContentLength ?? 0; + + // Validate file size - if it's too small (e.g. < 1MB), it's likely an error page + if (fileSize < 1024 * 1024) + { + logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes). Likely blocked.", url, fileSize); + continue; + } + + details.Add($"✓ Downloaded {fileSize / 1024 / 1024:F2} MB from {uri.Host}"); + + logger.LogInformation("Reading response content to memory..."); + var fileBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + + logger.LogInformation("Writing {Size} bytes to disk...", fileBytes.Length); + await File.WriteAllBytesAsync(downloadPath, fileBytes, cancellationToken); + + if (!isExe) + { + // Validate integrity by attempting to open the archive + try + { + using var archive = ZipFile.OpenRead(downloadPath); + var entryCount = archive.Entries.Count; + logger.LogInformation("Validated zip archive from {Url} ({Count} entries)", url, entryCount); + } + catch (Exception ex) + { + logger.LogWarning("Downloaded file from {Url} is corrupt: {Error}. Trying next mirror.", url, ex.Message); + continue; + } + } + + downloaded = true; + break; + } + catch (Exception ex) + { + logger.LogWarning("Failed to download from {Url}: {Error}", url, ex.Message); + } + } + + if (!downloaded) + { + throw new HttpRequestException("Failed to download Zero Hour 1.04 Patch from all mirrors."); + } + + if (isExe) + { + details.Add("Running Zero Hour 1.04 Patch Installer..."); + logger.LogInformation("Executing installer {Path}...", downloadPath); + + var process = Process.Start(new ProcessStartInfo + { + FileName = downloadPath, + Arguments = string.Empty, // Standard installer, interactive is fine if silent fails, but usually no args for this old patch or /S + UseShellExecute = true, + Verb = "runas", + }); + + if (process != null) + { + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode == 0) + details.Add("✓ Patch installer completed successfully"); + else + details.Add($"⚠ Patch installer exited with code {process.ExitCode}"); + } + else + { + details.Add("✗ Failed to start patch installer"); + return new ActionSetResult(false, "Failed to start patch installer", details); + } + } + else + { + details.Add("Extracting patch files..."); + logger.LogInformation("Extracting Zero Hour 1.04 patch..."); + + if (Directory.Exists(extractPath)) + Directory.Delete(extractPath, true); + + Directory.CreateDirectory(extractPath); + ZipFile.ExtractToDirectory(downloadPath, extractPath); + + var extractedFiles = Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories); + details.Add($"✓ Extracted {extractedFiles.Length} files"); + + // Copy files to game directory + details.Add($"Installing to: {installation.ZeroHourPath}"); + logger.LogInformation("Copying patch files to {Path}", installation.ZeroHourPath); + + int copiedCount = 0; + foreach (var file in extractedFiles) + { + var relativePath = file[extractPath.Length..].TrimStart(Path.DirectorySeparatorChar); + var destPath = Path.Combine(installation.ZeroHourPath, relativePath); + + var destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + if (!Path.GetFullPath(destPath).StartsWith(Path.GetFullPath(installation.ZeroHourPath), StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Skipping file {File} due to path traversal detected.", relativePath); + continue; + } + + File.Copy(file, destPath, true); + logger.LogDebug("Copied {File}", relativePath); + copiedCount++; + } + + details.Add($"✓ Installed {copiedCount} files"); + } + + 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 + { + // Cleanup + if (File.Exists(downloadPath)) + { + try + { + File.Delete(downloadPath); + } + catch + { + } + } + + if (Directory.Exists(extractPath)) + { + try + { + Directory.Delete(extractPath, true); + } + catch + { + } + } + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + logger.LogWarning("Uninstalling Zero Hour 1.04 patch is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..c7781eeb2 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/Patch108Fix.cs @@ -0,0 +1,185 @@ +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.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +namespace GenHub.Windows.Features.ActionSets.Fixes; + +/// +/// Installs the Generals 1.08 official patch. +/// +/// The HTTP client factory. +/// The logger instance. +public class Patch108Fix(IHttpClientFactory httpClientFactory, ILogger logger) : BaseActionSet(logger) +{ + /// + /// Gets the description of the fix. + /// + public static string Description => "Official Generals 1.08 patch - required for multiplayer and compatibility."; + + /// + public override string Id => "Patch108"; + + /// + public override string Title => "Generals 1.08 Patch"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Disabled per user request - redundant with GenHub Downloads section + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check if generals.exe version is 1.08 + var gameExePath = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GeneralsExe); + if (!File.Exists(gameExePath)) + { + return Task.FromResult(false); + } + + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + // 1.08 version should be 1.8.0.0 or similar + if (version != null && version.StartsWith("1.8")) + { + 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 cancellationToken) + { + var details = new List(); + + var tempPath = Path.Combine(Path.GetTempPath(), "gn108_patch.zip"); + var extractPath = Path.Combine(Path.GetTempPath(), "gn108_extract"); + + try + { + details.Add("Starting Generals 1.08 patch installation..."); + details.Add($"Target directory: {installation.GeneralsPath}"); + + 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"); + using var response = await client.GetAsync(ExternalUrls.Generals108PatchUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fileSize = response.Content.Headers.ContentLength ?? 0; + details.Add($"✓ Downloaded {fileSize / 1024 / 1024:F2} MB"); + + using var fs = new FileStream(tempPath, FileMode.Create); + await response.Content.CopyToAsync(fs, cancellationToken); + fs.Close(); + + details.Add("Extracting patch files..."); + logger.LogInformation("Extracting Generals 1.08 patch..."); + + if (Directory.Exists(extractPath)) + Directory.Delete(extractPath, true); + + Directory.CreateDirectory(extractPath); + ZipFile.ExtractToDirectory(tempPath, extractPath); + + var extractedFiles = Directory.GetFiles(extractPath, "*.*", SearchOption.AllDirectories); + details.Add($"✓ Extracted {extractedFiles.Length} files"); + + // Copy files to game directory + details.Add($"Installing to: {installation.GeneralsPath}"); + logger.LogInformation("Copying patch files to {Path}", installation.GeneralsPath); + + int copiedCount = 0; + foreach (var file in extractedFiles) + { + var relativePath = file[extractPath.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); + logger.LogDebug("Copied {File}", relativePath); + copiedCount++; + } + + details.Add($"✓ Installed {copiedCount} files"); + + details.Add("✓ Cleanup completed"); + 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"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + finally + { + // Cleanup + if (File.Exists(tempPath)) + { + try + { + File.Delete(tempPath); + } + catch + { + } + } + + if (Directory.Exists(extractPath)) + { + try + { + Directory.Delete(extractPath, true); + } + catch + { + } + } + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + logger.LogWarning("Uninstalling Generals 1.08 patch is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..438cb9eb9 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/PreferIPv4Fix.cs @@ -0,0 +1,157 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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 const string RegistryPath = @"SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters"; + private const string DisabledComponentsKey = "DisabledComponents"; + private const int PreferIPv4Value = 32; // Disable IPv6 tunnel interfaces + + private readonly IRegistryService _registryService = registryService; + private readonly ILogger _logger = logger; + + /// + public override string Id => "PreferIPv4Fix"; + + /// + public override string Title => "Prefer IPv4"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + var currentValue = _registryService.GetIntValue( + RegistryPath, + DisabledComponentsKey); + + var isApplied = currentValue == PreferIPv4Value; + 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 cancellationToken) + { + var details = new List(); + + try + { + details.Add("Checking current IPv6 configuration..."); + + var currentValue = _registryService.GetIntValue( + RegistryPath, + DisabledComponentsKey); + + details.Add($"Current DisabledComponents value: {currentValue}"); + + if (currentValue == PreferIPv4Value) + { + 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)); + } + + details.Add("Configuring system to prefer IPv4..."); + details.Add($"Registry: HKLM\\{RegistryPath}"); + details.Add($"Key: {DisabledComponentsKey}"); + details.Add($"New value: {PreferIPv4Value} (0x20 - Disable IPv6 tunnel interfaces)"); + + _logger.LogInformation("Enabling IPv4 preference by disabling IPv6 tunnel interfaces..."); + + _registryService.SetIntValue( + RegistryPath, + DisabledComponentsKey, + PreferIPv4Value); + + 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", details.Count); + _logger.LogInformation("NOTE: You may need to restart your computer for this change to take effect."); + + return Task.FromResult(new ActionSetResult(true, "IPv4 preference enabled. Restart required.", 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 cancellationToken) + { + var details = new List(); + + try + { + details.Add("Removing IPv4 preference..."); + + var currentValue = _registryService.GetStringValue( + RegistryPath, + DisabledComponentsKey); + + if (currentValue == null) + { + 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.LogInformation("Removing IPv4 preference..."); + + _registryService.SetIntValue( + RegistryPath, + DisabledComponentsKey, + 0); + + details.Add("✓ IPv4 preference removed successfully"); + details.Add("⚠ Computer restart required for changes to take effect"); + + _logger.LogInformation("IPv4 preference removed successfully."); + _logger.LogInformation("NOTE: You may need to restart your computer for this change to take effect."); + + return Task.FromResult(new ActionSetResult(true, "IPv4 preference removed. Restart required.", 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..aea4eb0fa --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ProxyLauncher.cs @@ -0,0 +1,96 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using Microsoft.Extensions.Logging; + +/// +/// Fix that provides information about proxy-based launching. +/// This fix explains the proxy launcher system used by GenHub. +/// +public class ProxyLauncher(ILogger logger) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + private readonly string _markerPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "GenHub", "sub_markers", "ProxyLauncher.done"); + + /// + public override string Id => "ProxyLauncher"; + + /// + public override string Title => "Proxy Launcher"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + return Task.FromResult(File.Exists(_markerPath)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking proxy launcher status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + try + { + // Provide information about proxy launcher + _logger.LogInformation("Proxy Launcher Information:"); + _logger.LogInformation("GenHub uses a proxy launcher system for game execution."); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Benefits of Proxy Launcher:"); + _logger.LogInformation("- Improved compatibility with modern Windows versions"); + _logger.LogInformation("- Better process isolation"); + _logger.LogInformation("- Enhanced error handling and logging"); + _logger.LogInformation("- Support for custom launch parameters"); + _logger.LogInformation("- Integration with GenHub's ActionSet framework"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("The proxy launcher is automatically used when launching games through GenHub."); + _logger.LogInformation("No manual configuration is required."); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create marker file for ProxyLauncher"); + } + + return Task.FromResult(new ActionSetResult(true, "Proxy launcher is built into GenHub and automatically used.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying proxy launcher fix"); + return Task.FromResult(new ActionSetResult(false, ex.Message)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Proxy Launcher Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..42582957e --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/RemoveReadOnlyFix.cs @@ -0,0 +1,297 @@ +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 = ".gp_ro_fix"; + + private readonly ILogger _logger = logger; + + private static string GetUserDataPath(GameType gameType) + { + var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var folder = gameType == GameType.ZeroHour + ? "Command and Conquer Generals Zero Hour Data" + : "Command and Conquer Generals Data"; + 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) + { + logger.LogWarning("Access denied to {Path}", directory.FullName); + } + + return (filesProcessed, dirsProcessed); + } + + /// + public override string Id => "RemoveReadOnlyFix"; + + /// + public override string Title => "Remove Read-Only Attributes"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + // We check if any of the root folders or key files are read-only. + // Full deep check is too slow for UI responsiveness, so we check a subset. + if (installation.HasGenerals) + { + if (IsReadOnly(installation.GeneralsPath)) return Task.FromResult(false); + + var userPath = GetUserDataPath(GameType.Generals); + if (Directory.Exists(userPath)) + { + // check for marker file + var markerPath = Path.Combine(userPath, MarkerFileName); + if (!File.Exists(markerPath)) return Task.FromResult(false); + + if (IsReadOnly(userPath)) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Options.ini"))) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Maps"))) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Replays"))) return Task.FromResult(false); + } + } + + if (installation.HasZeroHour) + { + if (IsReadOnly(installation.ZeroHourPath)) return Task.FromResult(false); + + var userPath = GetUserDataPath(GameType.ZeroHour); + if (Directory.Exists(userPath)) + { + if (IsReadOnly(userPath)) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Options.ini"))) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Maps"))) return Task.FromResult(false); + if (IsReadOnly(Path.Combine(userPath, "Replays"))) 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; + } + } + + 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; + } + } + + details.Add($"✓ Processed {totalFilesProcessed} files and {totalDirsProcessed} directories"); + details.Add("✓ Read-only attributes removed successfully"); + details.Add("✓ OneDrive pin attributes applied"); + + try + { + var userPath = GetUserDataPath(installation.HasZeroHour ? GameType.ZeroHour : GameType.Generals); + if (Directory.Exists(userPath)) + { + var markerPath = Path.Combine(userPath, MarkerFileName); + await File.WriteAllTextAsync(markerPath, DateTime.UtcNow.ToString(), ct); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create marker file for RemoveReadOnlyFix"); + } + + _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 cancellationToken) + { + _logger.LogWarning("Undoing Remove Read-Only Attributes is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } + + 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 = "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); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to apply pin attributes to {Path}", path); + } + } +} 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..33a94ed20 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/SerialKeyFix.cs @@ -0,0 +1,181 @@ +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"; + + private readonly IRegistryService _registryService = registryService; + private readonly ILogger _logger = logger; + + /// + public override string Id => "SerialKeyFix"; + + /// + public override string Title => "Fix Serial Keys"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + 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) + { + 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); + } + + // If we get here, keys are valid, so IsApplied is false (because it's Not Applicable) + // But if we return false here, and IsApplicable is false, it shows "NOT APPLICABLE" (Gray) + // If we return true here, and IsApplicable is false, it shows "APPLIED" (Green) + // We want "NOT APPLICABLE" if keys are already good. + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking serial key status"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + details.Add("Checking game serial keys..."); + var randomSerial = GenerateRandomSerial(); + + if (installation.HasGenerals) + { + var serial = _registryService.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) + { + details.Add(" Found placeholder serial for Generals. Generating new one..."); + if (_registryService.SetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, randomSerial)) + { + details.Add($" ✓ Applied new serial to {RegistryConstants.EAAppGeneralsErgcKeyPath}"); + } + else + { + details.Add(" ✗ Failed to apply new serial for Generals (permissions?)"); + } + } + else + { + details.Add(" ✓ Generals serial is already valid"); + } + } + + if (installation.HasZeroHour) + { + var serial = _registryService.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty); + if (IsPlaceholder(serial)) + { + details.Add(" Found placeholder serial for Zero Hour. Generating new one..."); + + // We can use the same or different serial. GenPatcher uses same for both if applied together. + if (_registryService.SetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty, randomSerial)) + { + details.Add($" ✓ Applied new serial to {RegistryConstants.EAAppZeroHourErgcKeyPath}"); + } + else + { + details.Add(" ✗ Failed to apply new serial for Zero Hour (permissions?)"); + } + } + else + { + details.Add(" ✓ Zero Hour serial is already valid"); + } + } + + 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 cancellationToken) + { + _logger.LogWarning("Undoing Serial Key Fix is not supported."); + return Task.FromResult(new ActionSetResult(true)); + } + + private static bool IsPlaceholder(string? serial) + { + if (string.IsNullOrEmpty(serial)) return true; + + var s = serial.Trim(); + return s == PlaceholderSerial1 || + s == PlaceholderSerialZero || + s == PlaceholderSerialDashes; + } + + private static string GenerateRandomSerial() + { + var random = new Random(); + var serial = "GP2"; + for (int i = 0; i < 17; i++) + { + serial += random.Next(0, 10).ToString(); + } + + return serial; + } +} 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..606290f2d --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/StartMenuFix.cs @@ -0,0 +1,201 @@ +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.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) +{ + private readonly IShortcutService _shortcutService = shortcutService; + private readonly ILogger _logger = logger; + + /// + public override string Id => "StartMenuFix"; + + /// + public override string Title => "Start Menu Shortcuts"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + 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 cancellationToken) + { + var details = new List(); + + try + { + details.Add("Creating Start Menu shortcuts..."); + + var commonPrograms = Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms); + + if (installation.HasGenerals) + { + var startMenuPath = Path.Combine(commonPrograms, "Command and Conquer Generals"); + var exe = Path.Combine(installation.GeneralsPath, "Generals.exe"); + + if (File.Exists(exe)) + { + var shortcutPath = Path.Combine(startMenuPath, "Command & Conquer Generals Windowed.lnk"); + var result = await _shortcutService.CreateShortcutAsync( + shortcutPath, + exe, + "-win", + installation.GeneralsPath, + "Launch Generals in Windowed Mode"); + + if (result.Success) + { + details.Add($"✓ Created: {Path.GetFileName(shortcutPath)}"); + } + else + { + details.Add($"✗ Failed to create Generals shortcut: {result.Errors.FirstOrDefault()}"); + } + } + } + + if (installation.HasZeroHour) + { + var startMenuPath = Path.Combine(commonPrograms, "Command and Conquer Generals Zero Hour"); + var exe = Path.Combine(installation.ZeroHourPath, "generals.exe"); + + if (File.Exists(exe)) + { + var shortcutPath = Path.Combine(startMenuPath, "Command & Conquer Generals Zero Hour Windowed.lnk"); + var result = await _shortcutService.CreateShortcutAsync( + shortcutPath, + exe, + "-win", + installation.ZeroHourPath, + "Launch Zero Hour in Windowed Mode"); + + if (result.Success) + { + details.Add($"✓ Created: {Path.GetFileName(shortcutPath)}"); + } + else + { + details.Add($"✗ Failed to create Zero Hour shortcut: {result.Errors.FirstOrDefault()}"); + } + } + + // EdgeScroller shortcut + var edgeScroller = Path.Combine(installation.ZeroHourPath, "EdgeScroller.exe"); + if (File.Exists(edgeScroller)) + { + var shortcutPath = Path.Combine(startMenuPath, "EdgeScroller.lnk"); + var result = await _shortcutService.CreateShortcutAsync( + shortcutPath, + edgeScroller, + null, + installation.ZeroHourPath, + "Window Edge Scroller"); + + if (result.Success) + { + details.Add($"✓ Created: {Path.GetFileName(shortcutPath)}"); + } + } + } + + details.Add(string.Empty); + details.Add("✓ Start Menu shortcuts created successfully"); + + 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 cancellationToken) + { + _logger.LogWarning("Undoing Start Menu Shortcuts Fix is not supported."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool DoShortcutsExist(GameInstallation installation) + { + var searchPaths = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), + Environment.GetFolderPath(Environment.SpecialFolder.Programs), + }; + + var generalsFound = !installation.HasGenerals; + var zhFound = !installation.HasZeroHour; + + foreach (var programsPath in searchPaths) + { + if (installation.HasGenerals && !generalsFound) + { + // Try both variants of '&' vs 'and' + var folderVariants = new[] { "Command and Conquer Generals", "Command & Conquer Generals" }; + foreach (var folder in folderVariants) + { + var path = Path.Combine(programsPath, folder, "Command & Conquer Generals Windowed.lnk"); + if (File.Exists(path)) + { + generalsFound = true; + break; + } + } + } + + if (installation.HasZeroHour && !zhFound) + { + var folderVariants = new[] { "Command and Conquer Generals Zero Hour", "Command & Conquer Generals Zero Hour" }; + foreach (var folder in folderVariants) + { + var path = Path.Combine(programsPath, folder, "Command & Conquer Generals Zero Hour Windowed.lnk"); + if (File.Exists(path)) + { + zhFound = true; + break; + } + } + } + } + + return generalsFound && zhFound; + } +} 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..a4e2887b2 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/TheFirstDecadeRegistryFix.cs @@ -0,0 +1,158 @@ +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) +{ + private readonly IRegistryService _registryService = registryService; + private readonly ILogger _logger = logger; + + /// + public override string Id => "TheFirstDecadeRegistryFix"; + + /// + public override string Title => "The First Decade Registry"; + + /// + public override bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(installation.HasGenerals || installation.HasZeroHour); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + 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 cancellationToken) + { + 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 + _registryService.SetStringValue( + RegistryConstants.TheFirstDecadeKeyPath, + RegistryConstants.InstallPathValueName, + tfdPath); + + _registryService.SetStringValue( + RegistryConstants.TheFirstDecadeKeyPath, + RegistryConstants.VersionValueName, + RegistryConstants.TfdVersionValue); + + details.Add("✓ Created: HKCU\\SOFTWARE\\EA Games\\Command & Conquer The First Decade"); + details.Add($" • InstallPath = {tfdPath}"); + details.Add($" • Version = {RegistryConstants.TfdVersionValue}"); + 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 cancellationToken) + { + _logger.LogWarning("Undoing TFD Registry Fix is not recommended as it may break game detection."); + return Task.FromResult(new ActionSetResult(true)); + } + + private string? FindTFDPath(string gamePath) + { + try + { + var directory = new DirectoryInfo(gamePath); + + // Check if we're already in a TFD structure + // TFD typically has structure: TFD\Command & Conquer Generals\... + if (directory.Parent?.Parent?.Name.Equals("Command & Conquer The First Decade", StringComparison.OrdinalIgnoreCase) == true) + { + return directory.Parent.Parent.FullName; + } + + // Check if parent is "Command & Conquer Generals" or similar + if (directory.Parent?.Name.Contains("Generals", StringComparison.OrdinalIgnoreCase) == true) + { + // Check if grandparent is TFD + if (directory.Parent.Parent?.Name.Contains("First Decade", StringComparison.OrdinalIgnoreCase) == true) + { + return directory.Parent.Parent.FullName; + } + } + + // Default to current path if we can't determine TFD structure + return 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..604a70ed0 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2005Fix.cs @@ -0,0 +1,170 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +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) : BaseActionSet(logger) +{ + // Product Code for VC++ 2005 SP1 Redistributable (x86) + // Common code: {7299052b-02a4-4627-81f2-1818da5d550d} + // But checking multiple reliable keys is safer. + private const string Vc2005ProductCode = "{7299052b-02a4-4627-81f2-1818da5d550d}"; + + private readonly ILogger _logger = logger; + + /// + public override string Id => "VCRedist2005Fix"; + + /// + public override string Title => "Visual C++ 2005 Redistributable"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + if (IsProductInstalled(Vc2005ProductCode)) return Task.FromResult(true); + + // Also check registry key existence generally + var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Classes\Installer\Products\b25099274a207264182f8181ad555dd0"); // Compressed GUID + return Task.FromResult(key != null); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var tempFile = Path.Combine(Path.GetTempPath(), "vcredist_2005_x86.exe"); + var details = new List(); + + try + { + details.Add("Downloading Visual C++ 2005 Redistributable..."); + + 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.VCRedist2005DownloadUrlPrimary, ExternalUrls.VCRedist2005DownloadUrlMirror1 }; + bool downloaded = false; + + foreach (var url in urls) + { + try + { + _logger.LogInformation("Attempting download from {Url}", url); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + using (var fs = new FileStream(tempFile, FileMode.Create)) + { + await response.Content.CopyToAsync(fs, cancellationToken); + } + + // Simple size validation check (Should be ~2.6MB) + if (new FileInfo(tempFile).Length < ActionSetConstants.Validation.VCRedistMinSize) + { + _logger.LogWarning("Downloaded file too small, likely corrupt."); + continue; + } + + details.Add($"✓ Downloaded from {new Uri(url).Host}"); + downloaded = true; + break; + } + catch (Exception ex) + { + _logger.LogWarning("Failed to download from {Url}: {Error}", url, ex.Message); + } + } + + if (!downloaded) + { + return new ActionSetResult(false, "Failed to download VCRedist 2005 from all mirrors.", details); + } + + details.Add("Installing Visual C++ 2005..."); + + var psi = new ProcessStartInfo + { + FileName = tempFile, + Arguments = "/Q", // Quiet install + UseShellExecute = true, + Verb = "runas", + }; + + using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start installer."); + + await process.WaitForExitAsync(cancellationToken); + + // 3010 = Reboot required + if (process.ExitCode == 0 || process.ExitCode == 3010) + { + return new ActionSetResult(true, "Visual C++ 2005 installed successfully.", details); + } + else + { + return new ActionSetResult(false, $"Installer exited with code {process.ExitCode}", details); + } + } + catch (Exception ex) + { + return new ActionSetResult(false, $"Error: {ex.Message}", details); + } + finally + { + if (File.Exists(tempFile)) + { + try + { + File.Delete(tempFile); + } + catch + { + } + } + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + return Task.FromResult(new ActionSetResult(true, "Uninstalling runtime not supported automatically. Use Control Panel.")); + } + + private static bool IsProductInstalled(string productCode) + { + try + { + using var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{productCode}"); + return key != null; + } + catch + { + return 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..522c97201 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2008Fix.cs @@ -0,0 +1,175 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +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) : BaseActionSet(logger) +{ + // Product Code for VC++ 2008 SP1 Redistributable (x86) + private const string Vc2008ProductCode = "{9A25302D-30C0-39D9-BD6F-21E6EC160475}"; + + private readonly ILogger _logger = logger; + + /// + public override string Id => "VCRedist2008Fix"; + + /// + public override string Title => "Visual C++ 2008 Redistributable"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + if (IsProductInstalled(Vc2008ProductCode)) + { + return Task.FromResult(true); + } + + // Also check registry key existence generally + var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Classes\Installer\Products\D20352A90C039D93DBF6126ECE614057"); // Compressed GUID + return Task.FromResult(key != null); + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var tempFile = Path.Combine(Path.GetTempPath(), "vcredist_2008_x86.exe"); + var details = new List(); + + try + { + details.Add("Downloading Visual C++ 2008 Redistributable..."); + + 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.VCRedist2008DownloadUrlPrimary, + ExternalUrls.VCRedist2008DownloadUrlMirror1, + }; + bool downloaded = false; + + foreach (var url in urls) + { + try + { + _logger.LogInformation("Attempting download from {Url}", url); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + + using (var fs = new FileStream(tempFile, FileMode.Create)) + { + await response.Content.CopyToAsync(fs, cancellationToken); + } + + // Simple size validation check + if (new FileInfo(tempFile).Length < ActionSetConstants.Validation.VCRedistMinSize) + { + _logger.LogWarning("Downloaded file too small, likely corrupt."); + continue; + } + + details.Add($"✓ Downloaded from {new Uri(url).Host}"); + downloaded = true; + break; + } + catch (Exception ex) + { + _logger.LogWarning("Failed to download from {Url}: {Error}", url, ex.Message); + } + } + + if (!downloaded) + { + return new ActionSetResult(false, "Failed to download VCRedist 2008 from all mirrors.", details); + } + + details.Add("Installing Visual C++ 2008..."); + + var psi = new ProcessStartInfo + { + FileName = tempFile, + Arguments = "/q", // 2008 uses /q + UseShellExecute = true, + Verb = "runas", + }; + + using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start installer."); + + await process.WaitForExitAsync(cancellationToken); + + // 3010 = Reboot required + if (process.ExitCode == 0 || process.ExitCode == 3010) + { + return new ActionSetResult(true, "Visual C++ 2008 installed successfully.", details); + } + else + { + return new ActionSetResult(false, $"Installer exited with code {process.ExitCode}", details); + } + } + catch (Exception ex) + { + return new ActionSetResult(false, $"Error: {ex.Message}", details); + } + finally + { + if (File.Exists(tempFile)) + { + try + { + File.Delete(tempFile); + } + catch + { + } + } + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + return Task.FromResult(new ActionSetResult(true, "Uninstalling runtime not supported automatically. Use Control Panel.")); + } + + private static bool IsProductInstalled(string productCode) + { + try + { + using var key = Registry.LocalMachine.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{productCode}"); + return key != null; + } + catch + { + return 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..9e4194acb --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VCRedist2010Fix.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Models.GameInstallations; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Windows.Features.ActionSets.Fixes; + +/// +/// Installs the Visual C++ 2010 Redistributable (x86) which is required for Generals/Zero Hour. +/// +/// The HTTP client factory. +/// The logger instance. +public class VCRedist2010Fix(IHttpClientFactory httpClientFactory, ILogger logger) : BaseActionSet(logger) +{ + /// + /// Gets the description of the fix. + /// + public static string Description => "Mandatory dependency for C&C Generals and Zero Hour errors."; + + /// + public override string Id => "VCRedist2010"; + + /// + public override string Title => "Visual C++ 2010 Runtime"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => false; // Network failures shouldn't abort entire sequence + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // This fix is applicable regardless of installation path as it's a system dependency + return Task.FromResult(true); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + // Check specific registry key for VC++ 2010 x86 + 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); + } + } + + // Fallback check: try WOW6432Node + 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.LogError(ex, "Failed to check VCRedist registry status"); + return Task.FromResult(false); + } + } + + /// + protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + details.Add("Starting Visual C++ 2010 Runtime installation..."); + details.Add($"Download URL: {ExternalUrls.VCRedist2010DownloadUrl}"); + + var tempPath = Path.Combine(Path.GetTempPath(), "vcredist_x86_2010.exe"); + details.Add($"Temp file: {tempPath}"); + + details.Add("Downloading VCRedist 2010..."); + logger.LogInformation("Downloading VCRedist 2010 from {Url}", ExternalUrls.VCRedist2010DownloadUrl); + + using var client = httpClientFactory.CreateClient("Downloader"); + using var response = await client.GetAsync(ExternalUrls.VCRedist2010DownloadUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + var fileSize = response.Content.Headers.ContentLength ?? 0; + details.Add($"✓ Downloaded {fileSize / 1024 / 1024:F2} MB"); + + using var fs = new FileStream(tempPath, FileMode.Create); + await response.Content.CopyToAsync(fs, cancellationToken); + fs.Close(); + + details.Add("Installing VCRedist 2010 (silent mode)..."); + details.Add(" ⚠ This may require administrator privileges"); + logger.LogInformation("Installing VCRedist 2010..."); + + var psi = new ProcessStartInfo + { + FileName = tempPath, + Arguments = "/q /norestart", // Silent install + UseShellExecute = true, + Verb = "runas", // Request elevation just in case + }; + + var process = Process.Start(psi); + if (process != null) + { + await process.WaitForExitAsync(cancellationToken); + + // 3010 is restart required + if (process.ExitCode != ProcessConstants.ExitCodeSuccess && process.ExitCode != 3010) + { + logger.LogWarning("VCRedist install exited with code {Code}", process.ExitCode); + details.Add($"⚠ VCRedist install exited with code {process.ExitCode}"); + details.Add($"✗ Installation may have failed"); + return new ActionSetResult(false, $"VCRedist install failed with code {process.ExitCode}", details); + } + else + { + if (process.ExitCode == 3010) + { + details.Add("✓ VCRedist 2010 installed successfully"); + details.Add(" ⚠ System restart may be required"); + } + else + { + details.Add("✓ VCRedist 2010 installed successfully"); + } + + logger.LogInformation("VCRedist 2010 installed successfully"); + } + } + + // Cleanup + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + + details.Add("✓ VCRedist 2010 installation completed"); + return new ActionSetResult(true, null, details); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to install VCRedist 2010"); + details.Add($"✗ Error: {ex.Message}"); + return new ActionSetResult(false, ex.Message, details); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + logger.LogWarning("Uninstalling VCRedist 2010 is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..bc8285dd9 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/VanillaExecutableFix.cs @@ -0,0 +1,141 @@ +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.Core.Models.Results; +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) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + + /// + public override string Id => "VanillaExecutableFix"; + + /// + public override string Title => "Generals Executable Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // Only applicable for Generals installations + return Task.FromResult(installation.HasGenerals); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + if (!installation.HasGenerals) + { + return Task.FromResult(false); + } + + var generalsExePath = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GeneralsExe); + if (!File.Exists(generalsExePath)) + { + return Task.FromResult(false); + } + + // Check file version to verify it's 1.08 + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(generalsExePath); + var version = versionInfo.FileVersion; + + // 1.08 version should be 1.8.0.0 or similar + if (version != null && version.StartsWith("1.8")) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Generals executable version"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + if (!installation.HasGenerals) + { + details.Add("✗ Generals is not installed"); + return Task.FromResult(new ActionSetResult(false, "Generals is not installed in this installation.", details)); + } + + details.Add("Generals Executable Fix - Informational"); + details.Add(string.Empty); + details.Add("This fix ensures the Generals 1.08 patch is applied."); + details.Add("The actual patching is done by the 'Generals 1.08 Patch' fix."); + details.Add(string.Empty); + + var generalsExePath = Path.Combine(installation.GeneralsPath, ActionSetConstants.FileNames.GeneralsExe); + if (File.Exists(generalsExePath)) + { + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(generalsExePath); + var version = versionInfo.FileVersion; + + details.Add($"Current executable: {Path.GetFileName(generalsExePath)}"); + details.Add($"Current version: {version ?? "unknown"}"); + + if (version != null && version.StartsWith("1.8")) + { + details.Add("✓ Generals 1.08 patch is already applied"); + } + else + { + details.Add("⚠ Generals 1.08 patch needs to be applied"); + details.Add(" Please apply the 'Generals 1.08 Patch' fix"); + } + } + else + { + details.Add("⚠ Generals executable not found"); + details.Add($" Expected location: {generalsExePath}"); + } + + _logger.LogInformation("VanillaExecutableFix ensures Generals 1.08 patch is applied via Patch108Fix."); + + // This fix is a wrapper that ensures that the official patch is applied. + // The actual patching is done by Patch108Fix. + // This fix exists for compatibility with GenPatcher's fix structure. + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying VanillaExecutableFix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Undoing Generals Executable Fix is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} 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..19d091627 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/WindowsMediaFeaturePack.cs @@ -0,0 +1,159 @@ +namespace GenHub.Windows.Features.ActionSets.Fixes; + +using System; +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 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 ILogger _logger = 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 bool IsCoreFix => false; + + /// + public override bool IsCrucialFix => false; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // 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) + { + if (File.Exists(_markerPath)) return Task.FromResult(true); + return Task.FromResult(IsMediaFeaturePackInstalled()); + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + try + { + var mediaPackInstalled = IsMediaFeaturePackInstalled(); + + if (mediaPackInstalled) + { + _logger.LogInformation("Windows Media Feature Pack is already installed. No action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + // Check Windows version + 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."); + _logger.LogInformation("Your Windows version: {Version}", osVersion); + return Task.FromResult(new ActionSetResult(true, "Media Feature Pack not available for your Windows version.")); + } + + // Provide guidance for installing Media Feature Pack + _logger.LogWarning("Windows Media Feature Pack is not installed."); + _logger.LogInformation("To install Windows Media Feature Pack:"); + _logger.LogInformation("1. Open Windows Settings"); + _logger.LogInformation("2. Go to 'Apps' > 'Optional features'"); + _logger.LogInformation("3. Click 'Add a feature'"); + _logger.LogInformation("4. Search for 'Media Feature Pack'"); + _logger.LogInformation("5. Click 'Install'"); + _logger.LogInformation(string.Empty); + _logger.LogInformation("Alternatively, you can download it from Microsoft website:"); + _logger.LogInformation("https://support.microsoft.com/en-us/help/4033582/windows-media-feature-pack"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_markerPath)!); + File.WriteAllText(_markerPath, DateTime.UtcNow.ToString()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create marker file."); + } + + return Task.FromResult(new ActionSetResult(true, "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 cancellationToken) + { + _logger.LogWarning("Windows Media Feature Pack Fix is informational only. No undo action needed."); + return Task.FromResult(new ActionSetResult(true)); + } + + private bool IsMediaFeaturePackInstalled() + { + try + { + // Check for Media Feature Pack in registry + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\Packages", + false); + + if (key != null) + { + foreach (var subKeyName in key.GetSubKeyNames()) + { + if (subKeyName.Contains("MediaFeaturePack", StringComparison.OrdinalIgnoreCase)) + { + using var subKey = key.OpenSubKey(subKeyName, false); + if (subKey != null) + { + var installState = subKey.GetValue("InstallState") as string; + if (installState == "Installed") + { + _logger.LogInformation("Found Media Feature Pack: {Package}", subKeyName); + return true; + } + } + } + } + } + + // Check for Windows Media Player + 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; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error checking for Media Feature Pack"); + 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..92a424a7f --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/ZeroHourExecutableFix.cs @@ -0,0 +1,137 @@ +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.Core.Models.Results; +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) : BaseActionSet(logger) +{ + private readonly ILogger _logger = logger; + + /// + public override string Id => "ZeroHourExecutableFix"; + + /// + public override string Title => "Zero Hour Executable Fix"; + + /// + public override bool IsCoreFix => true; + + /// + public override bool IsCrucialFix => true; + + /// + public override Task IsApplicableAsync(GameInstallation installation) + { + // User requested to disable this fix as it is handled by the Downloads tab + return Task.FromResult(false); + } + + /// + public override Task IsAppliedAsync(GameInstallation installation) + { + try + { + if (!installation.HasZeroHour) + { + return Task.FromResult(false); + } + + var gameExePath = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameExe); + if (!File.Exists(gameExePath)) + { + return Task.FromResult(false); + } + + // Check file version to verify it's 1.04 + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + // 1.04 version should be 1.4.0.0 or similar + if (version != null && version.StartsWith("1.4")) + { + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Zero Hour executable version"); + return Task.FromResult(false); + } + } + + /// + protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + var details = new List(); + + try + { + if (!installation.HasZeroHour) + { + details.Add("✗ Zero Hour is not installed"); + return Task.FromResult(new ActionSetResult(false, "Zero Hour is not installed in this installation.", details)); + } + + details.Add("Zero Hour Executable Fix - Informational"); + details.Add(string.Empty); + details.Add("This fix ensures the Zero Hour 1.04 patch is applied."); + details.Add("Note: Automatic patching is currently disabled. Please use the Downloads section."); + details.Add(string.Empty); + + var gameExePath = Path.Combine(installation.ZeroHourPath, ActionSetConstants.FileNames.GameExe); + + if (File.Exists(gameExePath)) + { + var versionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(gameExePath); + var version = versionInfo.FileVersion; + + details.Add($"Current executable: {Path.GetFileName(gameExePath)}"); + details.Add($"Current version: {version ?? "unknown"}"); + + if (version != null && version.StartsWith("1.4")) + { + details.Add("✓ Zero Hour 1.04 patch is already applied"); + } + else + { + details.Add("⚠ Zero Hour 1.04 patch needs to be applied"); + details.Add(" Please use the 'Downloads' section in GenHub to get the 1.04 patch."); + } + } + else + { + details.Add("⚠ Zero Hour executable not found"); + details.Add($" Expected location: {gameExePath}"); + } + + return Task.FromResult(new ActionSetResult(true, null, details)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error applying ZeroHourExecutableFix"); + details.Add($"✗ Error: {ex.Message}"); + return Task.FromResult(new ActionSetResult(false, ex.Message, details)); + } + } + + /// + protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken cancellationToken) + { + _logger.LogWarning("Undoing Zero Hour Executable Fix is not supported via GenHub."); + return Task.FromResult(new ActionSetResult(true)); + } +} diff --git a/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs b/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs new file mode 100644 index 000000000..ac1930f8c --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/GenPatcherTool.cs @@ -0,0 +1,66 @@ +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) + { + var vm = _serviceProvider.GetRequiredService(); + if (vm != null) + { + view.DataContext = vm; + } + } + + 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..7d2d2f2d3 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/Infrastructure/IRegistryService.cs @@ -0,0 +1,175 @@ +using System; +using System.Security.Principal; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Windows.Features.ActionSets.Infrastructure; + +/// +/// 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. + /// + /// 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); + + /// + /// Sets a string value in the registry. + /// + /// 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); + + /// + /// Gets an integer value from the registry. + /// + /// 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); + + /// + /// Sets an integer value in the registry. + /// + /// 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); +} + +/// +/// Implementation of the registry service. +/// +public class RegistryService(ILogger logger) : IRegistryService +{ + private readonly ILogger _logger = logger; + + /// + /// 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; + } + } + + /// + /// Gets a string value from the registry. + /// + /// 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. + public string? GetStringValue(string keyPath, string valueName, bool useWow6432Node = true) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, 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; + } + } + + /// + /// Sets a string value in the registry. + /// + /// 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. + public bool SetStringValue(string keyPath, string valueName, string value, bool useWow6432Node = true) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, 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; + } + } + + /// + /// Gets an integer value from the registry. + /// + /// 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. + public int? GetIntValue(string keyPath, string valueName, bool useWow6432Node = true) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, 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; + } + } + + /// + /// Sets an integer value in the registry. + /// + /// 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. + public bool SetIntValue(string keyPath, string valueName, int value, bool useWow6432Node = true) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, 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; + } + } +} \ 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..95db9ce84 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/ActionSetViewModel.cs @@ -0,0 +1,292 @@ +namespace GenHub.Windows.Features.ActionSets.UI; + +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; + +/// +/// View model for an individual action set. +/// +public partial class ActionSetViewModel : ObservableObject +{ + /// + /// Gets the underlying action set. + /// + public IActionSet ActionSet { get; } + + private readonly GameInstallation _installation; + private readonly IRegistryService _registryService; + private readonly INotificationService _notificationService; + private readonly ILogger _logger; + + /// + /// Gets the title of the action set. + /// + public string Title => ActionSet.Title; + + /// + /// Gets the description of the action set. + /// + public string Description => $"Fix ID: {ActionSet.Id}"; // Placeholder description + + /// + /// Gets a value indicating whether this is a core fix. + /// + public bool IsCore => ActionSet.IsCoreFix; + + [ObservableProperty] + private bool isApplicable; + + [ObservableProperty] + private bool isApplied; + + /// + /// Gets a value indicating whether the fix can be applied. + /// + public bool CanApply => IsApplicable && !IsApplied; + + /// + /// Gets the display status of the action set. + /// + public string StatusDisplay => IsApplied ? "APPLIED" : (IsApplicable ? "NOT INSTALLED" : "NOT APPLICABLE"); + + /// + /// Gets the color for the status display. + /// + public string StatusColor => IsApplied ? "#44FF44" : (IsApplicable ? "#FFFFFF" : "#888888"); + + /// + /// Gets the background color for the status badge. + /// + public string StatusBackground => IsApplied ? "#2200FF00" : (IsApplicable ? "#22FFFFFF" : "#11FFFFFF"); + + /// + /// Gets the border color for the status badge. + /// + public string StatusBorder => IsApplied ? "#4400FF00" : (IsApplicable ? "#44FFFFFF" : "#22FFFFFF"); + + [ObservableProperty] + private AsyncRelayCommand _applyCommand; + + [ObservableProperty] + private AsyncRelayCommand _forceApplyCommand; + + /// + /// Initializes a new instance of the class. + /// + /// The action set. + /// The game installation. + /// The registry service. + /// The notification service. + /// The logger instance. + public ActionSetViewModel(IActionSet actionSet, GameInstallation installation, IRegistryService registryService, INotificationService notificationService, ILogger logger) + { + ActionSet = actionSet; + _installation = installation; + _registryService = registryService; + _notificationService = notificationService; + _logger = logger; + _applyCommand = new AsyncRelayCommand(ApplyAsync); + _forceApplyCommand = new AsyncRelayCommand(ForceApplyAsync); + + _logger.LogDebug( + "Created ActionSetViewModel for {Title} (ID={Id}, IsCore={IsCore})", + actionSet.Title, + actionSet.Id, + actionSet.IsCoreFix); + } + + /// + /// Checks the status of the action set (applicable and applied). + /// + /// A task representing the asynchronous operation. + public async Task CheckStatusAsync() + { + try + { + _logger.LogInformation( + "[GENPATCHER_CHECK_005] Checking status for {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + + IsApplicable = await ActionSet.IsApplicableAsync(_installation); + IsApplied = await ActionSet.IsAppliedAsync(_installation); + + _logger.LogInformation( + "Status check complete: {Title} - Applicable={Applicable}, Applied={Applied}", + ActionSet.Title, + IsApplicable, + IsApplied); + + // Notify dependent properties + OnPropertyChanged(nameof(CanApply)); + OnPropertyChanged(nameof(StatusDisplay)); + OnPropertyChanged(nameof(StatusColor)); + OnPropertyChanged(nameof(StatusBackground)); + OnPropertyChanged(nameof(StatusBorder)); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "[GENPATCHER_CHECK_006] Failed to check status for {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + throw; + } + } + + private async Task ApplyAsync() + { + if (!_registryService.IsRunningAsAdministrator()) + { + _logger.LogWarning( + "[GENPATCHER_FIX_008] Cannot apply {Title} - not running as administrator", + ActionSet.Title); + _notificationService.ShowError( + "Administrator Rights Required", + "Please restart GenHub as Administrator to apply this fix."); + return; + } + + try + { + _logger.LogInformation( + "[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); + var duration = (DateTime.UtcNow - startTime).TotalMilliseconds; + + await CheckStatusAsync(); + + if (result.Success) + { + var detailsText = result.Details.Count > 0 + ? result.FormatDetails() + : $"{ActionSet.Title} has been successfully applied."; + + _logger.LogInformation( + "✓ {Title} applied successfully in {Duration}ms - {Details}", + ActionSet.Title, + (int)duration, + result.Details.Count > 0 ? string.Join("; ", result.Details) : "No details provided"); + + _notificationService.ShowSuccess( + $"Fix Applied: {ActionSet.Title}", + detailsText); + } + else + { + var detailsText = result.Details.Count > 0 + ? result.FormatDetails() + : result.ErrorMessage ?? "Unknown error occurred."; + + _logger.LogError( + "✗ [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); + } + } + catch (System.Exception ex) + { + _logger.LogError( + ex, + "[GENPATCHER_FIX_011] Exception applying {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + _notificationService.ShowError( + "Failed to Apply Fix", + $"Could not apply {ActionSet.Title}: {ex.Message}"); + } + } + + private async Task ForceApplyAsync() + { + if (!_registryService.IsRunningAsAdministrator()) + { + _logger.LogWarning( + "[GENPATCHER_FIX_012] Cannot force apply {Title} - not running as administrator", + ActionSet.Title); + _notificationService.ShowError( + "Administrator Rights Required", + "Please restart GenHub as Administrator for force apply."); + return; + } + + try + { + _logger.LogInformation( + "[GENPATCHER_FIX_013] Starting FORCE application of {Title} (ID={Id}) to {InstallPath}", + ActionSet.Title, + ActionSet.Id, + _installation.InstallationPath); + + var startTime = DateTime.UtcNow; + var result = await ActionSet.ApplyAsync(_installation); + var duration = (DateTime.UtcNow - startTime).TotalMilliseconds; + + await CheckStatusAsync(); + + if (result.Success) + { + var detailsText = result.Details.Count > 0 + ? result.FormatDetails() + : $"{ActionSet.Title} has been force applied successfully."; + + _logger.LogInformation( + "✓ {Title} force applied successfully in {Duration}ms - {Details}", + ActionSet.Title, + (int)duration, + result.Details.Count > 0 ? string.Join("; ", result.Details) : "No details provided"); + + _notificationService.ShowSuccess( + $"Fix Force Applied: {ActionSet.Title}", + detailsText); + } + else + { + var detailsText = result.Details.Count > 0 + ? result.FormatDetails() + : result.ErrorMessage ?? "Unknown error occurred."; + + _logger.LogError( + "✗ [GENPATCHER_FIX_014] {Title} force apply 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); + } + } + catch (System.Exception ex) + { + _logger.LogError( + ex, + "[GENPATCHER_FIX_015] Exception force applying {Title} (ID={Id})", + ActionSet.Title, + ActionSet.Id); + _notificationService.ShowError( + "Failed to Force Apply Fix", + $"Could not apply {ActionSet.Title}: {ex.Message}"); + } + } +} 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..0e87c7a08 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..a8090cc8c --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherToolView.axaml.cs @@ -0,0 +1,37 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace GenHub.Windows.Features.ActionSets.UI; + +/// +/// 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, Avalonia.VisualTreeAttachmentEventArgs e) + { + // Only initialize once + AttachedToVisualTree -= OnAttachedToVisualTree; + + if (DataContext is GenPatcherViewModel vm) + { + await vm.InitializeAsync(); + } + } + + 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..d76621ae7 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/ActionSets/UI/GenPatcherViewModel.cs @@ -0,0 +1,308 @@ +namespace GenHub.Windows.Features.ActionSets.UI; + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenHub.Core.Features.ActionSets; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameInstallations; +using GenHub.Windows.Features.ActionSets.Infrastructure; +using Microsoft.Extensions.Logging; + +/// +/// ViewModel for the GenPatcher feature. +/// +public partial class GenPatcherViewModel( + IActionSetOrchestrator orchestrator, + IGameInstallationDetector installationDetector, + IRegistryService registryService, + INotificationService notificationService, + ILogger logger) : ObservableObject +{ + private GameInstallation? currentInstallation; + + [ObservableProperty] + private ObservableCollection actionSets = []; + + /// + /// 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 = registryService.IsRunningAsAdministrator(); + 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); + } + + [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 installationDetector.DetectInstallationsAsync(); + var detected = result.Items; + + logger.LogInformation("Found {Count} game installation(s)", detected.Count); + foreach (var inst in detected) + { + logger.LogDebug( + "Installation: {InstallType} at {Path}", + inst.InstallationType, + inst.InstallationPath); + } + + GameInstallation? preferred = null; + foreach (var item in detected) + { + if (item.InstallationType != GameInstallationType.Unknown) + { + preferred = item; + break; + } + } + + currentInstallation = preferred ?? (detected.Count > 0 ? detected[0] : null); + + if (currentInstallation == null) + { + 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; + } + + logger.LogInformation( + "Using installation: {InstallType} at {Path}", + currentInstallation.InstallationType, + currentInstallation.InstallationPath); + + var fixes = orchestrator.GetAllActionSets(); + logger.LogInformation("Loading {Count} action sets...", fixes.Count()); + ActionSets.Clear(); + + var installation = currentInstallation; + + // Parallelize status checks to prevent UI blocking + var tasks = new List>(); + foreach (var fix in fixes) + { + tasks.Add(Task.Run(async () => + { + var vm = new ActionSetViewModel(fix, installation, registryService, notificationService, logger); + await vm.CheckStatusAsync(); + return vm; + })); + } + + var loadedVms = await Task.WhenAll(tasks); + foreach (var vm in loadedVms) + { + 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); + } + + 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.\nApplied: {appliedAndApplicableCount} / {applicableCount} applicable fixes."); + } + 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}"); + } + } + + [RelayCommand] + private async Task ApplyAllFixesAsync() + { + if (currentInstallation == null) + { + logger.LogError("[GENPATCHER_APPLY_004] Cannot apply fixes - no installation selected"); + return; + } + + 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 applicableFixes = new List(); + foreach (var vm in ActionSets) + { + if (vm.IsApplicable && !vm.IsApplied) + { + applicableFixes.Add(vm.ActionSet); + } + } + + 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."); + return; + } + + logger.LogInformation( + "[GENPATCHER_APPLY_006] Starting batch application of {Count} fixes: {FixList}", + applicableFixes.Count, + string.Join(", ", applicableFixes.Select(f => f.Id))); + + notificationService.ShowInfo( + "Applying Fixes", + $"Starting to apply {applicableFixes.Count} fix(es)...\nThis may take a few minutes."); + + // Apply fixes one by one with progress notifications + int successCount = 0; + var errors = new List(); + var startTime = DateTime.UtcNow; + + for (int i = 0; i < applicableFixes.Count; i++) + { + var fix = applicableFixes[i]; + var fixNumber = i + 1; + var total = applicableFixes.Count; + + // Show notification for current fix + notificationService.ShowInfo( + $"Applying Fix {fixNumber}/{total}", + $"⚙ {fix.Title}"); + + logger.LogInformation( + "[{Current}/{Total}] Applying {Title} (ID={Id})", + fixNumber, + total, + fix.Title, + fix.Id); + + var fixStartTime = DateTime.UtcNow; + + // Apply the fix + var fixResult = await fix.ApplyAsync(currentInstallation); + + var duration = (DateTime.UtcNow - fixStartTime).TotalMilliseconds; + + if (fixResult.Success) + { + successCount++; + notificationService.ShowSuccess( + $"✓ Fix {fixNumber}/{total} Applied", + fix.Title); + logger.LogInformation( + "✓ [{Title}] Success in {Duration}ms", + fix.Title, + (int)duration); + } + else + { + var errorMsg = $"{fix.Title}: {fixResult.ErrorMessage}"; + errors.Add(errorMsg); + notificationService.ShowWarning( + $"✗ Fix {fixNumber}/{total} Failed", + $"{fix.Title}\n{fixResult.ErrorMessage}"); + logger.LogError( + "✗ [GENPATCHER_FIX_007] {Title} failed in {Duration}ms - {Error}", + fix.Title, + (int)duration, + fixResult.ErrorMessage); + } + } + + var totalDuration = (DateTime.UtcNow - startTime).TotalSeconds; + + // Refresh status + logger.LogInformation("Refreshing fix status after batch application..."); + foreach (var vm in ActionSets) + { + await vm.CheckStatusAsync(); + } + + // Provide detailed summary + var failureCount = applicableFixes.Count - successCount; + + logger.LogInformation( + "Batch complete in {Duration}s - {Success}/{Total} successful, {Failed} failed", + totalDuration, + successCount, + applicableFixes.Count, + failureCount); + + if (errors.Count > 0) + { + var errorDetails = string.Join("\n\n", errors); + + logger.LogWarning("Batch completed with {Count} error(s): {Errors}", errors.Count, string.Join("; ", errors)); + notificationService.ShowError( + $"Fixes Completed with Errors ({successCount}/{applicableFixes.Count} successful)", + $"✓ Successfully applied: {successCount}\n✗ Failed: {failureCount}\n\nErrors:\n{errorDetails}"); + } + else + { + notificationService.ShowSuccess( + "All Fixes Applied Successfully", + $"✓ Successfully applied all {applicableFixes.Count} fix(es).\n\nYour game installation has been optimized!"); + } + } +} 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..0507803c4 100644 --- a/GenHub/GenHub.Windows/GenHub.Windows.csproj +++ b/GenHub/GenHub.Windows/GenHub.Windows.csproj @@ -21,6 +21,7 @@ + diff --git a/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs b/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs index ab228d31c..ffe7ef541 100644 --- a/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs +++ b/GenHub/GenHub.Windows/Infrastructure/DependencyInjection/WindowsServicesModule.cs @@ -1,17 +1,22 @@ +using GenHub.Core.Features.ActionSets; using GenHub.Core.Interfaces.GameInstallations; 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.GameInstallations; 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; using GenHub.Windows.GameInstallations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using System; namespace GenHub.Windows.Infrastructure.DependencyInjection; @@ -27,6 +32,9 @@ 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(); + // Register Windows-specific services services.AddSingleton(); services.AddSingleton(); @@ -41,6 +49,55 @@ 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(); + + // 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.AddTransient(); + 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 346bcb960..e068637b7 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostResolver.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -207,19 +206,9 @@ public Task> ResolveAsync( // 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; - } + builtManifest.Version = !string.IsNullOrEmpty(contentMetadata.Version) + ? contentMetadata.Version + : discoveredItem.Version; logger.LogInformation( "Successfully resolved Community Outpost manifest: {ManifestId} for {ContentCode} ({Category})", @@ -392,4 +381,4 @@ private List 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 2bdbe51ea..a6efe4219 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/GenPatcherDatCatalogParser.cs @@ -27,7 +27,6 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; public partial class GenPatcherDatCatalogParser(ILogger logger) : ICatalogParser { private static readonly string[] LineSeparators = ["\r\n", "\n"]; - private readonly ILogger _logger = logger; /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs index ff491f688..88c70d823 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/FileSystemDiscoverer.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Content; @@ -28,24 +27,6 @@ public class FileSystemDiscoverer : IContentDiscoverer private readonly ManifestDiscoveryService _manifestDiscoveryService; private readonly IConfigurationProviderService _configurationProvider; - /// - /// Initializes a new instance of the class. - /// - /// The logger instance. - /// The manifest discovery service. - /// The unified configuration provider. - public FileSystemDiscoverer( - ILogger logger, - ManifestDiscoveryService manifestDiscoveryService, - IConfigurationProviderService configurationProvider) - { - _logger = logger; - _manifestDiscoveryService = manifestDiscoveryService; - _configurationProvider = configurationProvider; - - InitializeContentDirectories(); - } - private static bool MatchesQuery(ContentManifest manifest, ContentSearchQuery query) { if (!string.IsNullOrWhiteSpace(query.SearchTerm) && @@ -68,6 +49,24 @@ private static bool MatchesQuery(ContentManifest manifest, ContentSearchQuery qu return true; } + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The manifest discovery service. + /// The unified configuration provider. + public FileSystemDiscoverer( + ILogger logger, + ManifestDiscoveryService manifestDiscoveryService, + IConfigurationProviderService configurationProvider) + { + _logger = logger; + _manifestDiscoveryService = manifestDiscoveryService; + _configurationProvider = configurationProvider; + + InitializeContentDirectories(); + } + /// public string SourceName => "Local File System"; @@ -115,7 +114,7 @@ public async Task> DiscoverAsync( ContentType = manifest.ContentType, TargetGame = manifest.TargetGame, ProviderName = SourceName, - AuthorName = manifest.Publisher?.Name ?? GameClientConstants.UnknownVersion, + AuthorName = manifest.Publisher?.Name ?? "Unknown", IconUrl = manifest.Metadata?.IconUrl ?? string.Empty, LastUpdated = manifest.Metadata?.ReleaseDate ?? DateTime.Now, DownloadSize = manifest.Files?.Sum(f => f.Size) ?? 0, @@ -149,11 +148,16 @@ public async Task> DiscoverAsync( } } - return OperationResult.CreateSuccess(new ContentDiscoveryResult + _logger.LogInformation("FileSystemDiscoverer found {Count} manifests matching query", discoveredItems.Count); + + var result = new ContentDiscoveryResult { Items = discoveredItems, HasMoreItems = false, - }); + TotalItems = discoveredItems.Count, + }; + + return OperationResult.CreateSuccess(result); } private void InitializeContentDirectories() diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs index 0e8469b37..baa28046a 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs @@ -63,6 +63,8 @@ public partial class GameProfileLauncherViewModel( private readonly System.Timers.Timer _headerCollapseTimer = new(TimeIntervals.HeaderCollapseDelayMs); private readonly System.Timers.Timer _headerExpansionTimer = new(TimeIntervals.HeaderExpansionDelayMs); + private bool _isHovering; + [ObservableProperty] private ObservableCollection _profiles = []; @@ -296,8 +298,6 @@ public void OnTabActivated() ResetHeaderState(); } - private bool _isHovering; - /// /// Resets the header state to expanded and restarts the auto-collapse timer. /// @@ -380,11 +380,51 @@ 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); + // Preserve the running state before updating + var wasRunning = existingItem.IsProcessRunning; + var processId = existingItem.ProcessId; + var workspaceId = existingItem.ActiveWorkspaceId; + + // Update the profile data + var gameTypeStr = profile.GameClient?.GameType.ToString() ?? "ZeroHour"; + + var iconPath = !string.IsNullOrEmpty(profile.IconPath) + ? profile.IconPath + : UriConstants.DefaultIconUri; + + var coverPath = !string.IsNullOrEmpty(profile.CoverPath) + ? profile.CoverPath + : profileResourceService.GetDefaultCoverPath(gameTypeStr); - logger.LogInformation("Refreshed profile {ProfileId} in-place (Running: {IsRunning})", profileId, existingItem.IsProcessRunning); + var newItem = new GameProfileItemViewModel( + profile.Id, + profile, + iconPath, + coverPath) + { + LaunchAction = LaunchProfileAsync, + EditProfileAction = EditProfile, + DeleteProfileAction = DeleteProfile, + CreateShortcutAction = CreateShortcut, + }; + + // Restore the running state + if (wasRunning) + { + newItem.IsProcessRunning = true; + newItem.ProcessId = processId; + } + + // Restore workspace state + if (!string.IsNullOrEmpty(workspaceId)) + { + newItem.UpdateWorkspaceStatus(workspaceId, profile.WorkspaceStrategy); + } + + var index = Profiles.IndexOf(existingItem); + Profiles[index] = newItem; + + logger.LogInformation("Refreshed profile {ProfileId} (Running: {IsRunning})", profileId, wasRunning); } } } @@ -609,13 +649,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 @@ -693,7 +732,7 @@ private async Task TryCreateProfileForGameClientAsync(GameInstallation ins // Logic must match GameInstallationService.GenerateAndPoolManifestForGameTypeAsync to ensure ID alignment string installationManifestId; if (string.IsNullOrEmpty(gameClient.Version) || - gameClient.Version.Equals(GameClientConstants.UnknownVersion, StringComparison.OrdinalIgnoreCase) || + gameClient.Version.Equals("Unknown", StringComparison.OrdinalIgnoreCase) || gameClient.Version.Equals("Auto-Updated", StringComparison.OrdinalIgnoreCase) || gameClient.Version.Equals(GameClientConstants.AutoDetectedVersion, StringComparison.OrdinalIgnoreCase)) { @@ -770,7 +809,7 @@ private async Task TryCreateProfileForGameClientAsync(GameInstallation ins } catch (Exception ex) { - logger.LogError(ex, "Error creating profile for {InstallationType} {GameClientName}", installation.InstallationType, gameClient?.Name ?? GameClientConstants.UnknownVersion); + logger.LogError(ex, "Error creating profile for {InstallationType} {GameClientName}", installation.InstallationType, gameClient?.Name ?? "Unknown"); return false; } } diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index 897b8efb5..328865754 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -364,7 +364,8 @@ private static void CategorizeRootSettings(IniOptions options, Dictionary audioDict = []; @@ -607,6 +608,7 @@ private static string[] SerializeOptionsIni(IniOptions options) lines.Add($"ExtraAnimations={BoolToString(options.Video.ExtraAnimations)}"); lines.Add($"Gamma={options.Video.Gamma}"); lines.Add($"AlternateMouseSetup={BoolToString(options.Video.AlternateMouseSetup)}"); + lines.Add($"HeatEffects={BoolToString(options.Video.HeatEffects)}"); lines.Add($"BuildingOcclusion={BoolToString(options.Video.BuildingOcclusion)}"); lines.Add($"ShowProps={BoolToString(options.Video.ShowProps)}"); diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationFeedViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationFeedViewModel.cs index decf4c691..a64076bfa 100644 --- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationFeedViewModel.cs +++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationFeedViewModel.cs @@ -28,13 +28,13 @@ public partial class NotificationFeedViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _isFeedOpen; - [ObservableProperty] - private int _unreadCount; - [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasUnreadNotifications))] private int _badgeCount; + [ObservableProperty] + private int _unreadCount; + /// /// Gets a value indicating whether there are unread notifications that should be shown in the badge. /// @@ -78,20 +78,35 @@ public NotificationFeedViewModel( /// Disposes of managed resources. /// public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of managed resources. + /// + /// True if disposing managed resources. + protected virtual void Dispose(bool disposing) { if (_disposed) + { return; + } - _historySubscription?.Dispose(); - - foreach (var item in NotificationHistory) + if (disposing) { - item?.Dispose(); + _historySubscription?.Dispose(); + + foreach (var item in NotificationHistory) + { + item?.Dispose(); + } + + NotificationHistory.Clear(); } - NotificationHistory.Clear(); _disposed = true; - GC.SuppressFinalize(this); } /// diff --git a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs index a1ef9cd2c..e052bd395 100644 --- a/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ViewModels/ToolsViewModel.cs @@ -7,7 +7,9 @@ using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; using GenHub.Core.Interfaces.Tools; +using GenHub.Core.Messages; using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.ViewModels; @@ -18,14 +20,30 @@ namespace GenHub.Features.Tools.ViewModels; /// /// Initializes a new instance of the class. /// -/// 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 : ObservableObject { - private readonly IToolManager _toolService = toolService; - private readonly ILogger _logger = logger; - private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly IToolManager _toolService; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The tool service for managing plugins. + /// The logger instance. + /// The service provider for dependency injection. + public ToolsViewModel(IToolManager toolService, ILogger logger, IServiceProvider serviceProvider) + { + _toolService = toolService; + _logger = logger; + _serviceProvider = serviceProvider; + + // Subscribe to tool status messages + WeakReferenceMessenger.Default.Register(this, (r, m) => + { + ((ToolsViewModel)r).ShowStatusMessage(m.Message, m.Type); + }); + } [ObservableProperty] private IToolPlugin? _selectedTool; @@ -112,13 +130,13 @@ 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); + ShowStatusMessage($"⚠ An error occurred while loading tools: {ex.Message}", MessageType.Error); _logger.LogError(ex, "Error loading tools"); } finally @@ -166,7 +184,7 @@ private async Task AddToolAsync() var assemblyPath = files[0].Path.LocalPath; IsLoading = true; StatusMessage = "Installing tool..."; - SetStatusType(info: true); + SetStatusType(MessageType.Info); IsStatusVisible = true; var result = await _toolService.AddToolAsync(assemblyPath); @@ -176,12 +194,12 @@ private async Task AddToolAsync() InstalledTools.Add(result.Data); HasTools = true; SelectedTool = result.Data; - ShowStatusMessage($"✓ Tool '{result.Data.Metadata.Name}' v{result.Data.Metadata.Version} installed successfully.", success: true); + ShowStatusMessage($"✓ Tool '{result.Data.Metadata.Name}' v{result.Data.Metadata.Version} 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)); } @@ -191,7 +209,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"); } } @@ -206,7 +224,7 @@ 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; } @@ -214,7 +232,7 @@ private async Task RemoveToolAsync(IToolPlugin? tool = null) { IsLoading = true; StatusMessage = $"Removing tool '{toolToRemove.Metadata.Name}'..."; - SetStatusType(info: true); + SetStatusType(MessageType.Info); IsStatusVisible = true; // Deactivate the tool before removal @@ -242,13 +260,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)); } @@ -257,7 +275,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"); } } @@ -272,7 +290,7 @@ private async Task RefreshToolsAsync() { IsLoading = true; StatusMessage = "Refreshing tools..."; - SetStatusType(info: true); + SetStatusType(MessageType.Info); IsStatusVisible = true; // Store the current selection @@ -312,24 +330,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 @@ -367,7 +385,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 @@ -376,11 +394,11 @@ partial void OnSelectedToolChanged(IToolPlugin? oldValue, IToolPlugin? newValue) } } - private void SetStatusType(bool success = false, bool error = false, bool info = false) + private void SetStatusType(MessageType type) { - IsStatusSuccess = success; - IsStatusError = error; - IsStatusInfo = info; + IsStatusSuccess = type == MessageType.Success; + IsStatusError = type == MessageType.Error || type == MessageType.Warning; + IsStatusInfo = type == MessageType.Info; } /// @@ -406,14 +424,14 @@ private void CloseDetailsDialog() ToolForDetails = null; } - private async void ShowStatusMessage(string message, bool success = false, bool error = false, bool info = false) + private async void ShowStatusMessage(string message, MessageType type = MessageType.Info) { // Cancel any existing hide timer _statusHideCts?.Cancel(); _statusHideCts?.Dispose(); StatusMessage = message; - SetStatusType(success, error, info); + SetStatusType(type); IsStatusVisible = true; // Auto-hide after 5 seconds diff --git a/GenHub/GenHub/Features/Validation/FileSystemValidator.cs b/GenHub/GenHub/Features/Validation/FileSystemValidator.cs index 8986b1cd4..1d1a02add 100644 --- a/GenHub/GenHub/Features/Validation/FileSystemValidator.cs +++ b/GenHub/GenHub/Features/Validation/FileSystemValidator.cs @@ -26,7 +26,7 @@ public abstract class FileSystemValidator(ILogger logger, IFileHashProvider hash /// Directories to check. /// Cancellation token. /// List of validation issues. - protected static Task> ValidateDirectoriesAsync(string basePath, IEnumerable requiredDirectories, CancellationToken cancellationToken) + protected Task> ValidateDirectoriesAsync(string basePath, IEnumerable requiredDirectories, CancellationToken cancellationToken) { var issues = new List(); foreach (var dir in requiredDirectories) diff --git a/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs b/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs index e7ba7c6a9..002ba8e22 100644 --- a/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/IsSubscribedConverter.cs @@ -3,7 +3,6 @@ using System.Globalization; using Avalonia.Data.Converters; using GenHub.Core.Models.AppUpdate; -using GenHub.Features.AppUpdate.ViewModels; namespace GenHub.Infrastructure.Converters; @@ -13,23 +12,6 @@ namespace GenHub.Infrastructure.Converters; /// public class IsSubscribedConverter : IMultiValueConverter { - /// - /// Not implemented for this converter. - /// - /// The value to convert back. - /// The target types. - /// The converter parameter. - /// The culture info. - /// An empty array. - public static object?[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) - { - _ = value; - _ = targetTypes; - _ = parameter; - _ = culture; - return []; - } - /// public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { @@ -53,4 +35,21 @@ public class IsSubscribedConverter : IMultiValueConverter return false; } + + /// + /// Converts a value back to multiple values. + /// + /// The value to convert back. + /// The target types. + /// The converter parameter. + /// The culture to use. + /// An empty array as this converter does not support two-way binding. + public object?[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + { + _ = value; + _ = targetTypes; + _ = parameter; + _ = culture; + return Array.Empty(); + } } diff --git a/GenHub/GenHub/Infrastructure/Converters/ProfileSelectionConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ProfileSelectionConverter.cs index 45342c186..e269a9c03 100644 --- a/GenHub/GenHub/Infrastructure/Converters/ProfileSelectionConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/ProfileSelectionConverter.cs @@ -19,7 +19,14 @@ public class ProfileSelectionConverter : IMultiValueConverter /// public static ProfileSelectionConverter Instance { get; } = new(); - /// + /// + /// Converts multiple values to a single value. + /// + /// The values to convert. + /// The target type. + /// The converter parameter. + /// The culture to use. + /// The converted value. public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) { if (values.Count >= 2 && values[1] is GameProfile profile) @@ -37,4 +44,17 @@ public class ProfileSelectionConverter : IMultiValueConverter return null; } + + /// + /// Converts a value back to multiple values. + /// + /// The value to convert back. + /// The target types. + /// The converter parameter. + /// The culture to use. + /// An empty array as this converter does not support two-way binding. + public object?[] ConvertBack(object? value, Type[] targetTypes, object? parameter, CultureInfo culture) + { + return Array.Empty(); + } } diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs index f8001fbf5..e15d261ec 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs @@ -35,6 +35,9 @@ public static IServiceCollection AddSharedViewModelModule(this IServiceCollectio // Register MainViewModel (critical for app startup) services.AddSingleton(); + // Register NotificationFeedViewModel (required by MainViewModel) + services.AddSingleton(); + // Register tab ViewModels services.AddSingleton(); services.AddSingleton(); @@ -58,9 +61,6 @@ public static IServiceCollection AddSharedViewModelModule(this IServiceCollectio // Register PublisherCardViewModel as transient services.AddTransient(); - // Register NotificationFeedViewModel - services.AddSingleton(); - // Register factory for GameProfileItemViewModel (has required constructor parameters) services.AddTransient>(sp => (profileId, profile, icon, cover) => new GameProfileItemViewModel(profileId, profile, icon, cover)); diff --git a/build-release.ps1 b/build-release.ps1 index 92162c659..a30e8ffff 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -81,9 +81,30 @@ if ($null -ne $setupExe) { Write-Error "Could not find Setup.exe in Velopack output." } -# Cleanup temporary directories -Remove-Item -Path $tempPackDir -Recurse -Force -Remove-Item -Path $publishDir -Recurse -Force +# Cleanup temporary directories (with retry for locked files) +function Remove-DirectoryWithRetry { + param([string]$Path, [int]$MaxRetries = 3) + + for ($i = 1; $i -le $MaxRetries; $i++) { + try { + if (Test-Path $Path) { + Remove-Item -Path $Path -Recurse -Force -ErrorAction Stop + } + return $true + } catch { + if ($i -lt $MaxRetries) { + Write-Host "Cleanup attempt $i failed, retrying in 2 seconds..." -ForegroundColor Yellow + Start-Sleep -Seconds 2 + } else { + Write-Host "Warning: Could not fully clean up $Path (files may be locked)." -ForegroundColor Yellow + return $false + } + } + } +} + +Remove-DirectoryWithRetry -Path $tempPackDir | Out-Null +Remove-DirectoryWithRetry -Path $publishDir | Out-Null Write-Host "" Write-Host "--- Build Complete! ---" -ForegroundColor Cyan diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 843ae5d56..dbb93c2c6 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -47,23 +47,23 @@ URI scheme constants for handling different types of URIs and paths. Application-wide constants for GenHub. -| Constant | Value/Type | Description | -| ------------------------- | ------------------- | ------------------------------------------------ | -| `AppName` | `"GenHub"` | The name of the application | -| `AppVersion` | Dynamic (lazy) | Full semantic version from assembly | -| `DisplayVersion` | `"v" + AppVersion` | Display version for UI | -| `GitShortHash` | Dynamic | Short git commit hash (7 chars) | -| `GitShortHashLength` | `7` | Length of git short hash | -| `PullRequestNumber` | Dynamic | PR number if PR build | -| `BuildChannel` | Dynamic | Build channel (Dev, PR, CI, Release) | -| `IsCiBuild` | bool | Whether this is a CI/CD build | -| `FullDisplayVersion` | string | Full display version with hash | -| `GitHubRepositoryUrl` | `"https://github.com/community-outpost/GenHub"` | GitHub repository URL | -| `GitHubRepositoryOwner` | `"community-outpost"` | GitHub repository owner | -| `GitHubRepositoryName` | `"GenHub"` | GitHub repository name | -| `DefaultTheme` | `Theme.Dark` | Default UI theme | -| `DefaultThemeName` | `"Dark"` | Default theme name as string | -| `TokenFileName` | `".ghtoken"` | Default GitHub token file name | +| Constant | Value/Type | Description | +| ----------------------- | ----------------------------------------------- | ------------------------------------ | +| `AppName` | `"GenHub"` | The name of the application | +| `AppVersion` | Dynamic (lazy) | Full semantic version from assembly | +| `DisplayVersion` | `"v" + AppVersion` | Display version for UI | +| `GitShortHash` | Dynamic | Short git commit hash (7 chars) | +| `GitShortHashLength` | `7` | Length of git short hash | +| `PullRequestNumber` | Dynamic | PR number if PR build | +| `BuildChannel` | Dynamic | Build channel (Dev, PR, CI, Release) | +| `IsCiBuild` | bool | Whether this is a CI/CD build | +| `FullDisplayVersion` | string | Full display version with hash | +| `GitHubRepositoryUrl` | `"https://github.com/community-outpost/GenHub"` | GitHub repository URL | +| `GitHubRepositoryOwner` | `"community-outpost"` | GitHub repository owner | +| `GitHubRepositoryName` | `"GenHub"` | GitHub repository name | +| `DefaultTheme` | `Theme.Dark` | Default UI theme | +| `DefaultThemeName` | `"Dark"` | Default theme name as string | +| `TokenFileName` | `".ghtoken"` | Default GitHub token file name | --- @@ -71,11 +71,11 @@ Application-wide constants for GenHub. Constants related to application updates and Velopack. -| Constant | Value/Type | Description | -| ---------------------------- | --------------------------- | ------------------------------------------------ | -| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update | -| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks | -| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests | +| Constant | Value/Type | Description | +| :--- | :--- | :--- | +| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update | +| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks | +| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests | --- @@ -193,13 +193,13 @@ File and directory name constants to prevent typos and ensure consistency. ### Manifest Files -| Constant | Value | Description | -| ----------------------- | ------------------- | --------------------------------- | -| `ManifestsDirectory` | `"Manifests"` | Directory for manifest files | -| `ManifestFilePattern` | `"*.manifest.json"` | File pattern for manifest files | -| `ManifestFileExtension` | `".manifest.json"` | File extension for manifest files | -| `UserDataManifestExtension` | `".userdata.json"` | File extension for user data manifest files | -| `BackupExtension` | `".ghbak"` | File extension for backup files | +| Constant | Value | Description | +| --------------------------- | ------------------- | ------------------------------------------- | +| `ManifestsDirectory` | `"Manifests"` | Directory for manifest files | +| `ManifestFilePattern` | `"*.manifest.json"` | File pattern for manifest files | +| `ManifestFileExtension` | `".manifest.json"` | File extension for manifest files | +| `UserDataManifestExtension` | `".userdata.json"` | File extension for user data manifest files | +| `BackupExtension` | `".ghbak"` | File extension for backup files | ### JSON Files @@ -245,10 +245,9 @@ Constants related to manifest ID generation, validation, and file operations. ### Manifest ID Regex Patterns -| Constant | Description | -| -------------------------------- | ------------------------------- | -| `PublisherContentRegexPattern` | Regex for validating 5-segment publisher content IDs (schemaVersion.userVersion.publisher.contentType.contentName) | +| Constant | Description | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `PublisherContentRegexPattern` | Regex for validating 5-segment publisher content IDs (schemaVersion.userVersion.publisher.contentType.contentName) | **Publisher Content Regex Pattern (5-segment format):** @@ -281,14 +280,14 @@ Constants related to manifest ID generation, validation, and file operations. Constants related to Steam integration and the proxy launcher. -| Constant | Value | Description | -| ------------------------ | ------------------------- | ------------------------------------------------ | -| `GeneralsAppId` | `"17300"` | Steam AppID for Generals | -| `ZeroHourAppId` | `"2732960"` | Steam AppID for Zero Hour | -| `TrackingFileName` | `".genhub-files.json"` | Tracking file for Steam launches | -| `BackupDirName` | `".genhub-backup"` | Backup directory for original game files | -| `BackupExtension` | `".ghbak"` | Extension for backed up game executables | -| `ProxyLauncherFileName` | `"GenHub.ProxyLauncher.exe"` | Filename of the proxy launcher executable | +| Constant | Value | Description | +| ----------------------- | ---------------------------- | ----------------------------------------- | +| `GeneralsAppId` | `"17300"` | Steam AppID for Generals | +| `ZeroHourAppId` | `"2732960"` | Steam AppID for Zero Hour | +| `TrackingFileName` | `".genhub-files.json"` | Tracking file for Steam launches | +| `BackupDirName` | `".genhub-backup"` | Backup directory for original game files | +| `BackupExtension` | `".ghbak"` | Extension for backed up game executables | +| `ProxyLauncherFileName` | `"GenHub.ProxyLauncher.exe"` | Filename of the proxy launcher executable | --- @@ -312,7 +311,7 @@ Extensible SHA-256 hash constants and registry for known game executables used f | `MaxExternalHashSources` | `50` | Maximum number of external hash sources that can be registered | | `ExternalSourceCacheTimeoutMinutes` | `30` | Cache timeout for external hash sources in minutes | -### Collections and Properties +### Hash Registry Collections | Property | Type | Description | | ------------------------- | -------------- | --------------------------------------------------------------------------------- | @@ -402,7 +401,7 @@ Well-known publisher type identifiers for content sources. Uses lowercase string | `LocalImport` | `"local"` | Local file import by user | | `FileSystem` | `"filesystem"` | Imported from file system | -### Generated Content +### Auto-Generated Content (Core) | Constant | Value | Description | | ---------------- | ----------------- | ----------------------------------------------- | @@ -410,7 +409,7 @@ Well-known publisher type identifiers for content sources. Uses lowercase string | `GenHubInternal` | `"genhub"` | GenHub internal system content | | `CsvGenerated` | `"csvgenerated"` | Content generated from CSV authoritative source | -### Special/Unknown +### Special and Unknown Sources | Constant | Value | Description | | --------- | ----------- | ------------------------------------- | @@ -570,7 +569,7 @@ These constants provide standardized publisher metadata for content attribution | `Website` | `"https://www.playgenerals.online/"` | Website URL for Generals Online | | `SupportUrl` | `"https://www.playgenerals.online/support"` | Support URL for Generals Online | -### Helper Methods +### Publisher Source Helper Methods **`GetPublisherInfo(GameInstallationType)`**: Returns a tuple containing (Name, Website, SupportUrl) for the specified installation type. @@ -602,13 +601,13 @@ Constants related to game client detection and management. ### Game Directory Names -| Constant | Value | Description | -| ---------------------------------- | ------------------------------------------ | ---------------------------------------------- | -| `GeneralsDirectoryName` | `"Command and Conquer Generals"` | Standard Generals installation directory name | -| `ZeroHourDirectoryName` | `"Command and Conquer Generals Zero Hour"` | Standard Zero Hour installation directory name | +| Constant | Value | Description | +| -------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- | +| `GeneralsDirectoryName` | `"Command and Conquer Generals"` | Standard Generals installation directory name | +| `ZeroHourDirectoryName` | `"Command and Conquer Generals Zero Hour"` | Standard Zero Hour installation directory name | | `ZeroHourDirectoryNameAmpersandHyphen` | `"Command & Conquer Generals - Zero Hour"` | Zero Hour directory name with ampersand and hyphen (Steam standard) | -| `ZeroHourDirectoryNameColonVariant` | `"Command & Conquer: Generals - Zero Hour"` | Zero Hour directory name with colon variant | -| `ZeroHourDirectoryNameAbbreviated` | `"C&C Generals Zero Hour"` | Zero Hour directory name abbreviated form | +| `ZeroHourDirectoryNameColonVariant` | `"Command & Conquer: Generals - Zero Hour"` | Zero Hour directory name with colon variant | +| `ZeroHourDirectoryNameAbbreviated` | `"C&C Generals Zero Hour"` | Zero Hour directory name abbreviated form | ### GeneralsOnline Client Detection @@ -669,8 +668,8 @@ Enum for game client display names used in UI formatting and content display. ### Enum Values -| Value | Description | -| ---------- | ------------------------------------ | +| Value | Description | +| ---------- | ------------------------------------- | | `Generals` | Command & Conquer: Generals | | `ZeroHour` | Command & Conquer: Generals Zero Hour | @@ -857,11 +856,11 @@ Storage and CAS (Content-Addressable Storage) related constants. --- -## GameClientHashRegistry Class +## GameClientHashRegistry Class (Extended Registry) Extensible SHA-256 hash constants and registry for known game executables used for client detection across official and 3rd party distributions. Supports dynamic updates, external hash databases, and plugin extensibility. -### Core Hash Constants +### Core Hash Constants (Registry) | Constant | Value | Description | | ----------------- | -------------------------------------------------------------------- | --------------------------------------------------------- | @@ -869,7 +868,7 @@ Extensible SHA-256 hash constants and registry for known game executables used f | `ZeroHour104Hash` | `"f37a4929f8d697104e99c2bcf46f8d833122c943afcd87fd077df641d344495b"` | SHA-256 hash for Zero Hour 1.04 executable (generals.exe) | | `ZeroHour105Hash` | `"420fba1dbdc4c14e2418c2b0d3010b9fac6f314eafa1f3a101805b8d98883ea1"` | SHA-256 hash for Zero Hour 1.05 executable (generals.exe) | -### Extensibility Configuration Constants +### Extensibility Configuration (Registry) | Constant | Value | Description | | ----------------------------------- | ------------------------------- | -------------------------------------------------------------- | @@ -877,13 +876,13 @@ Extensible SHA-256 hash constants and registry for known game executables used f | `MaxExternalHashSources` | `50` | Maximum number of external hash sources that can be registered | | `ExternalSourceCacheTimeoutMinutes` | `30` | Cache timeout for external hash sources in minutes | -### Collections and Properties +### Registry Collections | Property | Type | Description | | ------------------------- | -------------- | --------------------------------------------------------------------------------- | | `PossibleExecutableNames` | `List` | Executable file names that might contain game executables (extensible at runtime) | -### Basic Usage Example +### Registry Usage Snippet ```csharp using GenHub.Core.Constants; @@ -905,7 +904,7 @@ if (info.HasValue) } ``` -### Extensibility Usage +### Registry Extensibility Use Cases ```csharp using GenHub.Core.Constants; @@ -925,15 +924,15 @@ GameClientHashRegistry.AddPossibleExecutableName("my-modded-generals.exe"); --- -## PublisherTypeConstants Class +## PublisherTypeConstants Class Definition -Well-known publisher type identifiers for content sources. Uses lowercase string identifiers for consistency with the ManifestId system. +Well-known publisher type identifiers for content sources. ### Publisher Type Overview Publisher types are **string-based** (not an enum) for extensibility. Any string value is valid; these constants are just common identifiers for convenience. -### Official Platform Publishers +### Official Platform Identifiers | Constant | Value | Description | | ---------------- | ------------------ | --------------------------------- | @@ -944,7 +943,7 @@ Publisher types are **string-based** (not an enum) for extensibility. Any string | `Wine` | `"wine"` | Wine/Proton compatibility layer | | `CdIso` | `"cdiso"` | CD-ROM/ISO installation | -### Community Platforms +### Community Platform Identifiers | Constant | Value | Description | | ------------------ | -------------------- | --------------------------------------------------------- | @@ -953,21 +952,21 @@ Publisher types are **string-based** (not an enum) for extensibility. Any string | `ModDb` | `"moddb"` | ModDB hosting platform | | `CncLabs` | `"cnclabs"` | C&C Labs community site | -### Web/Download Sources +### Web Source Constants | Constant | Value | Description | | ------------- | ---------- | -------------------- | | `GitHub` | `"github"` | GitHub repository | | `WebDownload` | `"web"` | Generic web download | -### Local/System Sources +### Local and System Sources | Constant | Value | Description | | ------------- | -------------- | ------------------------- | | `LocalImport` | `"local"` | Local file import by user | | `FileSystem` | `"filesystem"` | Imported from file system | -### Generated Content +### Content Generation Metadata Constants | Constant | Value | Description | | ---------------- | ----------------- | ----------------------------------------------- | @@ -975,14 +974,14 @@ Publisher types are **string-based** (not an enum) for extensibility. Any string | `GenHubInternal` | `"genhub"` | GenHub internal system content | | `CsvGenerated` | `"csvgenerated"` | Content generated from CSV authoritative source | -### Special/Unknown +### Miscellaneous Publisher Types | Constant | Value | Description | | --------- | ----------- | ------------------------------------- | | `Unknown` | `"unknown"` | Unknown or unspecified publisher type | | `Custom` | `"custom"` | Custom user-defined publisher | -### Helper Methods +### Publisher Identification Utilities #### `FromInstallationType(GameInstallationType)` @@ -1005,7 +1004,7 @@ public static string FromInstallationType(GameInstallationType installationType) } ``` -### Publisher Type Usage Examples +### Publisher Constants Usage ```csharp using GenHub.Core.Constants; @@ -1043,7 +1042,7 @@ var customPublisher = "my-custom-publisher"; // Custom publishers work just like predefined constants ``` -### GeneralsOnline Publisher Type +### GeneralsOnline Special Publisher Type The `GeneralsOnline` publisher type is used for the GeneralsOnline community launcher, which provides auto-updated clients for Command & Conquer Generals and Zero Hour. @@ -1059,8 +1058,7 @@ The `GeneralsOnline` publisher type is used for the GeneralsOnline community lau - `1.0.generalsonline.gameclient.generalsonline_30hz` (GeneralsOnline 30Hz client) - `1.0.generalsonline.gameclient.generalsonline_60hz` (GeneralsOnline 60Hz client) -See also: [Manifest ID System Documentation](manifest-id-system.md) for complete ID format details. ---- +### Manifest ID Details ## Configuration and Usage Examples @@ -1285,7 +1283,9 @@ Constants for content pipeline component identifiers used in dependency injectio --- -## MaintenanceWhen adding new constants +## Maintenance + +When adding new constants 1. Choose the appropriate constants file based on functionality 2. Follow naming conventions (PascalCase for constants) @@ -1441,7 +1441,7 @@ Constants for The Super Hackers content discovery and manifest creation. - `PublisherPrefix`: Publisher prefix string (`"thesuperhackers"`) - `PublisherDisplayName`: Display name for the publisher (`"The Super Hackers"`) -- `VersionDelimiter`: Character used to separate components in version strings (`':'`) + ## ToolConstants Class Constants for tool plugin metadata and configuration. @@ -1450,18 +1450,17 @@ Constants for tool plugin metadata and configuration. Constants specific to the Replay Manager tool plugin. -| Constant | Value | Description | -| ------------ | ------------------------------------------ | ------------------------------------------------ | -| `Id` | `"genhub.tools.replaymanager"` | Unique identifier for the Replay Manager tool | -| `Name` | `"Replay Manager"` | Display name for the Replay Manager tool | -| `Version` | `"1.0.0"` | Version of the Replay Manager tool | -| `Author` | `"GenHub Team"` | Author of the Replay Manager tool | -| `Description`| `"Manage, import, and share replay files for Command & Conquer: Generals and Zero Hour."` | Description of the Replay Manager tool | -| `Tags` | `["replays", "file-management", "sharing"]`| Tags associated with the Replay Manager tool | -| `IconPath` | `"Assets/Icons/replay.png"` | Icon path for the Replay Manager tool (placeholder) | -| `IsBundled` | `true` | Whether the tool is bundled with the application | +| Constant | Value | Description | +| ----------- | ------------------------------------------- | --------------------------------------------------- | +| `Id` | `"genhub.tools.replaymanager"` | Unique identifier for the Replay Manager tool | +| `Name` | `"Replay Manager"` | Display name for the Replay Manager tool | +| `Version` | `"1.0.0"` | Version of the Replay Manager tool | +| `Author` | `"GenHub Team"` | Author of the Replay Manager tool | +| `Tags` | `["replays", "file-management", "sharing"]` | Tags associated with the Replay Manager tool | +| `IconPath` | `"Assets/Icons/replay.png"` | Icon path for the Replay Manager tool (placeholder) | +| `IsBundled` | `true` | Whether the tool is bundled with the application | -### Usage Example +### Replay Manager Usage Example ```csharp using GenHub.Core.Constants; @@ -1486,78 +1485,24 @@ var metadata = new ToolMetadata Constants specifically for the Map Manager feature. -| Constant | Value | Description | -| ------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------- | -| `MaxMapSizeBytes` | `10485760` (10MB) | Maximum file size for individual maps | -| `MaxWeeklyUploadBytes` | `104857600` (100MB) | Maximum weekly upload limit | -| `ThumbnailMaxWidth` | `128` | Maximum width for thumbnails | -| `ThumbnailMaxHeight` | `128` | Maximum height for thumbnails | -| `DefaultThumbnailName` | `"map.tga"` | Default thumbnail filename | -| `MaxDirectoryDepth` | `1` | Maximum directory nesting depth | -| `GeneralsDataDirectoryName` | `"Command and Conquer Generals Data"` | Directory name for Generals data | -| `ZeroHourDataDirectoryName` | `"Command and Conquer Generals Zero Hour Data"` | Directory name for Zero Hour data | -| `MapsSubdirectoryName` | `"Maps"` | Subdirectory name for maps | -| `MapPacksSubdirectoryName` | `"mappacks"` | Subdirectory name for MapPacks | -| `MapFilePattern` | `"*.map"` | File pattern for maps | -| `ZipFilePattern` | `"*.zip"` | File pattern for ZIPs | -| `DefaultZipName` | `"maps.zip"` | Default name for exported ZIPs | -| `ToolId` | `"map-manager"` | Unique identifier for Map Manager | -| `ToolName` | `"Map Manager"` | Display name for Map Manager | -| `ToolDescription` | `"Manage, import, and share custom maps. Create MapPacks for easy profile switching."` | Description of the tool | -## Content Provider Constants - -Constants for various community content providers and manifest generation. - -### CommunityOutpostCatalogConstants Class - -Constants related to the Community Outpost (GenPatcher) catalog and metadata. - -- `CatalogFilename`: Default filename for the GenPatcher catalog (`"GenPatcher.dat"`) -- `VersionKey`: Metadata key for version information (`"Version"`) -- `DescriptionKey`: Metadata key for description information (`"Description"`) -- `DownloadUrlKey`: Metadata key for download URLs (`"DownloadUrl"`) - -### GeneralsOnlineConstants Class - -Constants for Generals Online content discovery and manifest creation. - -- `PublisherPrefix`: Publisher prefix string (`"generalsonline"`) -- `PublisherId`: Publisher identifier (`"generals-online"`) -- `PublisherDisplayName`: Display name for the publisher (`"Generals Online"`) -- `QfeMarkerPrefix`: Prefix used for QFE (Quick Fix Engineering) versions (`"qfe-"`) -- `MapPackTags`: Default tags for MapPack manifests (`["mappack", "generalsonline"]`) -- `UnknownVersion`: Default version string when unknown (`"unknown"`) -- `CoverSource`: Default path for cover images (`"/Assets/Covers/zerohour-cover.png"`) - -### CNCLabsConstants Class - -Constants for CNC Labs (CNC Maps) content discovery and manifest creation. - -- `PublisherPrefix`: Publisher prefix string (`"cnclabs"`) -- `PublisherId`: Publisher identifier (`"cnc-labs"`) -- `PublisherName`: Display name for the publisher (`"CNC Labs"`) -- `PublisherWebsite`: Main website URL (`"https://www.cnclabs.com"`) -- `DefaultTags`: Default tags for CNC Labs manifests (`["cnclabs"]`) -- `DefaultDownloadFilename`: Default filename for downloads when parsing fails (`"download.zip"`) - -### ModDBConstants Class - -Constants for ModDB content discovery and manifest creation. - -- `PublisherPrefix`: Publisher prefix string (`"moddb"`) -- `PublisherDisplayName`: Display name for the publisher (`"ModDB"`) -- `PublisherWebsite`: Main website URL (`"https://www.moddb.com"`) -- `ReleaseDateFormat`: Date format used in ModDB metadata (`"MMMM dd, yyyy"`) -- `PublisherNameFormat`: Format string for including the author with the publisher name (`"ModDB ({0})"`) -- `DefaultDownloadFilename`: Default filename for downloads when parsing fails (`"download.zip"`) - -### SuperHackersConstants Class - -Constants for The Super Hackers content discovery and manifest creation. - -- `PublisherPrefix`: Publisher prefix string (`"thesuperhackers"`) -- `PublisherDisplayName`: Display name for the publisher (`"The Super Hackers"`) -- `VersionDelimiter`: Character used to separate components in version strings (`':'`) +| Constant | Value | Description | +| --------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `MaxMapSizeBytes` | `10485760` (10MB) | Maximum file size for individual maps | +| `MaxWeeklyUploadBytes` | `104857600` (100MB) | Maximum weekly upload limit | +| `ThumbnailMaxWidth` | `128` | Maximum width for thumbnails | +| `ThumbnailMaxHeight` | `128` | Maximum height for thumbnails | +| `DefaultThumbnailName` | `"map.tga"` | Default thumbnail filename | +| `MaxDirectoryDepth` | `1` | Maximum directory nesting depth | +| `GeneralsDataDirectoryName` | `"Command and Conquer Generals Data"` | Directory name for Generals data | +| `ZeroHourDataDirectoryName` | `"Command and Conquer Generals Zero Hour Data"` | Directory name for Zero Hour data | +| `MapsSubdirectoryName` | `"Maps"` | Subdirectory name for maps | +| `MapPacksSubdirectoryName` | `"mappacks"` | Subdirectory name for MapPacks | +| `MapFilePattern` | `"*.map"` | File pattern for maps | +| `ZipFilePattern` | `"*.zip"` | File pattern for ZIPs | +| `DefaultZipName` | `"maps.zip"` | Default name for exported ZIPs | +| `ToolId` | `"map-manager"` | Unique identifier for Map Manager | +| `ToolName` | `"Map Manager"` | Display name for Map Manager | +| `ToolDescription` | `"Manage, import, and share custom maps. Create MapPacks for easy profile switching."` | Description of the tool | --- diff --git a/docs/features/actionsets.md b/docs/features/actionsets.md new file mode 100644 index 000000000..a4000463c --- /dev/null +++ b/docs/features/actionsets.md @@ -0,0 +1,1078 @@ +# ActionSet Fixes + +This document provides comprehensive documentation for all ActionSet fixes available in GenHub for Command & Conquer: Generals and Zero Hour. + +## Overview + +ActionSets are automated fixes that resolve common issues with Command & Conquer: Generals and Zero Hour on modern Windows systems. Each fix addresses specific compatibility, performance, or functionality problems. + +## Critical Fixes + +These fixes are essential for the games to run properly on modern Windows systems. + +### BrowserEngineFix + +**Purpose**: Fixes in-game browser compatibility issues by disabling the problematic BrowserEngine.dll. + +**What It Does**: + +- Renames `BrowserEngine.dll` to `BrowserEngine.dll.bak` in game directories +- Prevents crashes and errors caused by outdated browser components +- Applies to both Generals and Zero Hour + +**How It Works**: + +1. Checks if `BrowserEngine.dll` exists in game installation directories +2. If found, renames it to `.bak` extension to disable it +3. The game will run without the browser engine (which is rarely used) + +**Files Modified**: + +- `{GeneralsPath}\BrowserEngine.dll` → `BrowserEngine.dll.bak` +- `{ZeroHourPath}\BrowserEngine.dll` → `BrowserEngine.dll.bak` + +**Reversible**: Yes - can restore by renaming `.bak` back to `.dll` + +--- + +### DbgHelpFix + +**Purpose**: Replaces outdated `dbghelp.dll` files that can cause crashes and debugging issues. + +**What It Does**: + +- Replaces `dbghelp.dll` in both Generals and Zero Hour directories +- Uses a modern version compatible with Windows 10/11 +- Prevents crashes during error reporting and debugging + +**How It Works**: + +1. Checks for existing `dbghelp.dll` in game directories +2. Backs up original file to `.bak` +3. Copies a modern `dbghelp.dll` from embedded resources +4. Verifies the replacement was successful + +**Files Modified**: + +- `{GeneralsPath}\dbghelp.dll` (replaced, original backed up) +- `{ZeroHourPath}\dbghelp.dll` (replaced, original backed up) + +**Reversible**: Yes - can restore from `.bak` backup + +--- + +### EAAppRegistryFix + +**Purpose**: Ensures EA App can properly detect game installations. + +**What It Does**: + +- Creates or updates registry entries for EA App detection +- Sets correct installation paths for Generals and Zero Hour +- Enables EA App integration features + +**How It Works**: + +1. Checks if EA App is installed +2. Creates registry keys under `HKLM\SOFTWARE\EA Games\` +3. Sets `InstallPath` values for both games +4. Sets version information for proper detection + +**Registry Keys Created/Modified**: + +- `HKLM\SOFTWARE\EA Games\Command and Conquer Generals\InstallPath` +- `HKLM\SOFTWARE\EA Games\Command and Conquer Generals Zero Hour\InstallPath` + +**Reversible**: Yes - registry keys can be deleted + +--- + +### MyDocumentsPathCompatibility + +**Purpose**: Ensures game data folders exist in Documents directory, even with non-English characters in path. + +**What It Does**: + +- Creates required game data folders in Documents +- Handles paths with Unicode/non-English characters +- Ensures proper folder structure for saves and settings + +**How It Works**: + +1. Locates Documents folder using Windows API +2. Creates `Command and Conquer Generals Data` folder if missing +3. Creates `Command and Conquer Generals Zero Hour Data` folder if missing +4. Creates subdirectories for saves, replays, and maps + +**Folders Created**: + +- `{Documents}\Command and Conquer Generals Data\` +- `{Documents}\Command and Conquer Generals Zero Hour Data\` +- Subdirectories: `Save`, `Replays`, `Maps` + +**Reversible**: No - folders are created but not deleted + +--- + +### VCRedist2010Fix + +**Purpose**: Installs Visual C++ 2010 Redistributable required by the game. + +**What It Does**: + +- Downloads and installs Visual C++ 2010 Redistributable +- Ensures required runtime libraries are present +- Fixes "MSVCR100.dll missing" errors + +**How It Works**: + +1. Checks if VC++ 2010 Redistributable is already installed +2. If not installed, downloads installer from Microsoft +3. Runs installer silently with administrator privileges +4. Verifies installation by checking for required DLLs + +**Files Installed**: + +- `msvcr100.dll`, `msvcp100.dll` (and variants) +- Installed to System32 and SysWOW64 directories + +**Reversible**: No - can be uninstalled through Windows Programs & Features + +--- + +### RemoveReadOnlyFix + +**Purpose**: Removes the read-only attribute from game files and ensures they are not "Pinned" in OneDrive. +**What It Does**: + +- Iterates through all files in game installation directories +- Removes read-only attribute using Windows API +- Applies OneDrive "Pinned" attribute to prevent syncing +- Ensures game files can be modified and saved properly + +**How It Works**: + +1. Iterates through all files in game installation directories +2. Removes read-only attribute using Windows API +3. Checks if files are in a OneDrive-managed folder +4. If so, applies `FILE_ATTRIBUTE_PINNED` using `SetFileAttributes` +5. This forces OneDrive to keep a local copy and allow game access + +Processes both Generals and Zero Hour installations + +**Files Modified**: + +- All files in `{GeneralsPath}` (read-only attribute removed) +- All files in `{GeneralsPath}` and `{ZeroHourPath}` +**Reversible**: Partially - read-only attributes are removed, but "Pinned" state remains + +--- + +### AppCompatConfigurationsFix + +**Purpose**: Sets Windows compatibility flags and adds Windows Defender exclusions for better performance. + +**What It Does**: + +- Enables High DPI awareness for proper scaling on modern displays +- Sets Run as Administrator compatibility for non-Steam installations +- Adds Windows Defender exclusions to prevent scanning interference +- Improves game performance and stability + +**How It Works**: + +1. Checks if game is installed via Steam +2. For Steam: Sets `~ HIGHDPIAWARE` compatibility flag +3. For other installations: Sets `~ RUNASADMIN HIGHDPIAWARE` flags +4. Adds game directories to Windows Defender exclusion list +5. Uses PowerShell `Add-MpPreference` command + +**Registry Keys Created/Modified**: + +- `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers\{GameExePath}` + +**Windows Defender Exclusions Added**: + +- `{GeneralsPath}` (directory exclusion) +- `{ZeroHourPath}` (directory exclusion) + +**Reversible**: Yes - registry keys and exclusions can be removed + +--- + +### DirectXRuntimeFix + +**Purpose**: Installs DirectX 8.1 and 9.0c runtime components required by the game. + +**What It Does**: + +- Downloads DirectX runtime installer +- Installs missing DirectX components +- Ensures proper 3D rendering and graphics functionality + +**How It Works**: + +1. Downloads DirectX runtime from official source +2. Extracts to temporary directory +3. Runs `DXSETUP.exe /silent` with administrator privileges +4. Verifies installation by checking for `D3DX9_43.dll` in SysWOW64 + +**Files Installed**: + +- DirectX 8.1 and 9.0c runtime components +- Installed to System32 and SysWOW64 directories + +**Reversible**: No - DirectX components can be uninstalled through Windows Features + +--- + +### Patch104Fix + +**Purpose**: Installs Zero Hour 1.04 official patch. + +**What It Does**: + +- Downloads Zero Hour 1.04 patch +- Applies patch to Zero Hour installation +- Updates game to latest official version + +**How It Works**: + +1. Downloads patch from official source +2. Extracts patch files to temporary directory +3. Copies files to Zero Hour installation directory +4. Verifies installation by checking `game.exe` version (should start with "1.4") + +**Files Modified**: + +- All files in `{ZeroHourPath}` updated to 1.04 versions +- `game.exe` version updated to 1.04 + +**Reversible**: No - official patch cannot be easily reverted + +--- + +### Patch108Fix + +**Purpose**: Installs Generals 1.08 official patch. + +**What It Does**: + +- Downloads Generals 1.08 patch +- Applies patch to Generals installation +- Updates game to latest official version + +**How It Works**: + +1. Downloads patch from official source +2. Extracts patch files to temporary directory +3. Copies files to Generals installation directory +4. Verifies installation by checking `generals.exe` version (should start with "1.8") + +**Files Modified**: + +- All files in `{GeneralsPath}` updated to 1.08 versions +- `generals.exe` version updated to 1.08 + +**Reversible**: No - official patch cannot be easily reverted + +--- + +### OptionsINIFix + +**Purpose**: Ensures optimal game settings in Options.ini for better performance and compatibility. + +**What It Does**: + +- Applies optimal settings to Options.ini files +- Improves performance and visual quality +- Ensures proper resolution and graphics settings + +**How It Works**: + +1. Loads Options.ini from game data folder in Documents +2. Applies optimal settings if not already set +3. Saves modified Options.ini +4. Works for both Generals and Zero Hour + +**Settings Applied**: + +- `DynamicLOD = no` (disables dynamic level of detail) +- `ExtraAnimations = yes` (enables extra animations) +- `HeatEffects = no` (disables heat effects for performance) +- `MaxParticleCount = 1000` (sets maximum particle count) +- `SendDelay = no` (disables send delay for better multiplayer) +- `ShowSoftWaterEdge = yes` (enables soft water edges) +- `ShowTrees = yes` (enables tree rendering) +- Resolution set to optimal value (avoids low resolutions) + +**Files Modified**: + +- `{Documents}\Command and Conquer Generals Data\Options.ini` +- `{Documents}\Command and Conquer Generals Zero Hour Data\Options.ini` + +**Reversible**: Yes - original values can be restored from backup + +--- + +### VanillaExecutableFix + +**Purpose**: Verifies that Generals 1.08 patch is properly applied. + +**What It Does**: + +- Checks `generals.exe` file version +- Confirms 1.08 patch is installed +- Provides status information + +**How It Works**: + +1. Uses `FileVersionInfo.GetVersionInfo()` to read executable version +2. Checks if version starts with "1.8" (indicating 1.08) +3. Returns status indicating if patch is applied +4. Only applicable for Generals installations + +**Files Checked**: + +- `{GeneralsPath}\generals.exe` (version check only) + +**Reversible**: N/A - informational check only + +--- + +### ZeroHourExecutableFix + +**Purpose**: Verifies that Zero Hour 1.04 patch is properly applied. + +**What It Does**: + +- Checks `game.exe` file version +- Confirms 1.04 patch is installed +- Provides status information + +**How It Works**: + +1. Uses `FileVersionInfo.GetVersionInfo()` to read executable version +2. Checks if version starts with "1.4" (indicating 1.04) +3. Returns status indicating if patch is applied +4. Only applicable for Zero Hour installations + +**Files Checked**: + +- `{ZeroHourPath}\game.exe` (version check only) + +**Reversible**: N/A - informational check only + +--- + +## Important Compatibility Fixes + +These fixes improve compatibility with Windows features and third-party software. + +### OneDriveFix + +**Purpose**: Prevents OneDrive from syncing game folders to avoid conflicts and performance issues. + +**What It Does**: + +- Creates `desktop.ini` files with `ThisPCPolicy=DisableCloudSync` +- Marks folders to prevent OneDrive synchronization +- Ensures game files remain local + +**How It Works**: + +1. Creates `desktop.ini` in game installation and user data folders +2. Sets `ThisPCPolicy=DisableCloudSync` to disable OneDrive sync +3. Marks `desktop.ini` as hidden and system file +4. Marks folder as system folder (read-only bit indicates system folder) +5. Processes both Generals and Zero Hour installations + +**Files Created**: + +- `{GeneralsPath}\desktop.ini` +- `{ZeroHourPath}\desktop.ini` +- `{Documents}\Command and Conquer Generals Data\desktop.ini` +- `{Documents}\Command and Conquer Generals Zero Hour Data\desktop.ini` + +**Reversible**: Yes - `desktop.ini` files can be deleted + +--- + +### EdgeScrollerFix + +**Purpose**: Improves edge scrolling for modern high-resolution displays. + +**What It Does**: + +- Adjusts edge scrolling sensitivity in Options.ini +- Makes edge scrolling more responsive on large monitors +- Improves gameplay experience with modern displays + +**How It Works**: + +1. Loads Options.ini from game data folder +2. Sets optimal edge scrolling values if not already configured +3. Saves modified Options.ini +4. Works for both Generals and Zero Hour + +**Settings Applied**: + +- `ScrollEdgeZone = 0.1` (edge detection zone size, range: 0.05-0.15) +- `ScrollEdgeSpeed = 1.5` (scrolling speed, range: 1.0-2.0) +- `ScrollEdgeAcceleration = 1.0` (scrolling acceleration) + +**Files Modified**: + +- `{Documents}\Command and Conquer Generals Data\Options.ini` +- `{Documents}\Command and Conquer Generals Zero Hour Data\Options.ini` + +**Reversible**: Yes - original values can be restored + +--- + +### TheFirstDecadeRegistryFix + +**Purpose**: Creates registry entries for The First Decade (TFD) version detection. + +**What It Does**: + +- Enables proper detection of TFD installations +- Sets TFD registry keys with correct paths +- Ensures compatibility with TFD version of the games + +**How It Works**: + +1. Detects TFD installation path by examining directory structure +2. Navigates up from game installation to find TFD root directory +3. Creates registry entries in `HKLM\SOFTWARE\EA Games\Command & Conquer The First Decade` +4. Sets `InstallPath` to TFD base directory +5. Sets `Version` to "1.03" + +**Registry Keys Created**: + +- `HKLM\SOFTWARE\EA Games\Command & Conquer The First Decade\InstallPath` +- `HKLM\SOFTWARE\EA Games\Command & Conquer The First Decade\Version` + +**Reversible**: Yes - registry keys can be deleted + +--- + +### CNCOnlineRegistryFix + +**Purpose**: Creates registry entries for C&C Online (Revora) multiplayer service. + +**What It Does**: + +- Enables proper detection and connection to C&C Online servers +- Creates game-specific registry entries +- Supports multiplayer functionality through C&C Online + +**How It Works**: + +1. Creates registry entries in `HKLM\SOFTWARE\Revora\CNCOnline` +2. Creates game-specific entries for Generals and Zero Hour +3. Sets `InstallPath` for each game installation +4. Sets `Version` (1.08 for Generals, 1.04 for Zero Hour) +5. Creates main C&C Online entry with base installation path + +**Registry Keys Created**: + +- `HKLM\SOFTWARE\Revora\CNCOnline\InstallPath` +- `HKLM\SOFTWARE\Revora\CNCOnline\Generals\InstallPath` +- `HKLM\SOFTWARE\Revora\CNCOnline\Generals\Version` +- `HKLM\SOFTWARE\Revora\CNCOnline\ZeroHour\InstallPath` +- `HKLM\SOFTWARE\Revora\CNCOnline\ZeroHour\Version` + +**Reversible**: Yes - registry keys can be deleted + +--- + +## Optional Enhancement Fixes + +These fixes provide additional improvements and guidance but are not essential for basic functionality. + +### MalwarebytesFix + +**Purpose**: Provides Malwarebytes compatibility guidance to prevent interference with game execution. + +**What It Does**: + +- Checks for Malwarebytes installation +- Provides step-by-step instructions to add game folders to exclusions +- Lists all game installation paths that should be excluded + +**How It Works**: + +1. Checks registry and file system for Malwarebytes installation +2. If installed, provides detailed instructions for adding exclusions +3. Lists all game installation paths to exclude +4. Explains how to access Malwarebytes exclusion settings + +**User Action Required**: + +- Open Malwarebytes +- Go to Settings > Exclusions +- Add game installation folders to exclusions list + +**Reversible**: N/A - informational fix only + +--- + +### D3D8XDLLCheck + +**Purpose**: Checks for DirectX 8 DLLs required by the game and provides guidance if missing. + +**What It Does**: + +- Verifies presence of required DirectX 8 DLLs +- Lists any missing DLLs +- Provides guidance to install missing components + +**How It Works**: + +1. Checks System32 and SysWOW64 directories for required DLLs +2. Lists all missing DLLs if any are not found +3. Provides guidance to run DirectXRuntimeFix +4. Checks for critical DLLs: d3d8.dll, d3dx8d.dll, d3dx9_43.dll, etc. + +**DLLs Checked**: + +- `d3d8.dll` +- `d3dx8d.dll` +- `d3dx9_43.dll` +- Other DirectX 8/9 runtime DLLs + +**User Action Required**: + +- Run DirectXRuntimeFix if DLLs are missing +- Or manually install DirectX runtime + +**Reversible**: N/A - informational fix only + +--- + +### NahimicFix + +**Purpose**: Provides Nahimic audio compatibility guidance to prevent audio issues. + +**What It Does**: + +- Checks for Nahimic audio driver installation +- Provides instructions to disable Nahimic service +- Explains potential audio conflicts + +**How It Works**: + +1. Checks registry and running processes for Nahimic +2. If installed, provides step-by-step instructions +3. Lists multiple methods to disable the service +4. Explains that Nahimic can cause audio issues with older games + +**User Action Required**: + +- Disable Nahimic service via Task Manager or Services +- Or uninstall Nahimic audio driver + +**Reversible**: N/A - informational fix only + +--- + +### DisableOriginInGame + +**Purpose**: Disables Origin in-game overlay to prevent performance issues and conflicts. + +**What It Does**: + +- Checks for Origin installation +- Checks Origin configuration for overlay status +- Provides instructions to disable overlay + +**How It Works**: + +1. Checks registry and processes for Origin installation +2. Checks Origin.ini configuration file for overlay setting +3. Provides step-by-step instructions to disable overlay +4. Explains how to disable overlay per-game + +**User Action Required**: + +- Open Origin client +- Go to Application Settings > Origin In-Game +- Uncheck "Enable Origin In-Game" +- Or disable per-game in game properties + +**Reversible**: N/A - informational fix only + +--- + +### GenArial + +**Purpose**: Ensures Arial font is available for proper text rendering in the game. + +**What It Does**: + +- Checks for Arial font files in Windows Fonts directory +- Checks for Arial font entries in Windows registry +- Provides instructions to install Arial font if missing + +**How It Works**: + +1. Checks `C:\Windows\Fonts\` for Arial font files +2. Checks Windows registry for Arial font entries +3. If missing, provides installation instructions +4. Lists multiple installation methods + +**User Action Required**: + +- Install Arial font via Windows Store +- Or copy from another PC +- Or download from Microsoft website + +**Reversible**: N/A - informational fix only + +--- + +### HDIconsFix + +**Purpose**: Provides information about high-definition icons for Generals and Zero Hour. + +**What It Does**: + +- Checks for HD icon files in game directories +- Provides information about HD icon availability +- Explains that HD icons are provided by GenHub's Content system + +**How It Works**: + +1. Checks game directories for HD icon files +2. Provides information about HD icon availability +3. Explains that HD icons are typically provided by mods or community content +4. References GenHub's Content system for icon downloads + +**User Action Required**: + +- Download HD icons through GenHub's Content system +- Or install mods that include HD icons + +**Reversible**: N/A - informational fix only + +--- + +### WindowsMediaFeaturePack + +**Purpose**: Checks for Windows Media Feature Pack installation required for some media playback features. + +**What It Does**: + +- Checks for Media Feature Pack in Windows registry +- Checks for Windows Media Player installation +- Provides instructions to install Media Feature Pack if missing + +**How It Works**: + +1. Checks Windows registry for Media Feature Pack entries +2. Checks for Windows Media Player executable +3. If missing, provides installation instructions +4. Only applicable for Windows 10 and later + +**User Action Required**: + +- Open Windows Settings > Apps > Optional features +- Click "Add a feature" +- Search for "Media Feature Pack" +- Click "Install" + +**Reversible**: N/A - informational fix only + +--- + +### GameRangerRunAsAdmin + +**Purpose**: Provides GameRanger compatibility guidance to ensure games run as administrator. + +**What It Does**: + +- Checks for GameRanger installation +- Checks if game executables have admin compatibility flags +- Provides instructions to configure GameRanger + +**How It Works**: + +1. Checks registry and processes for GameRanger installation +2. Checks if game executables have admin compatibility flags +3. Provides step-by-step instructions to configure GameRanger +4. Lists multiple methods to enable run as administrator + +**User Action Required**: + +- Open GameRanger +- Go to Edit > Game Settings +- Select Generals or Zero Hour +- Check "Run as Administrator" option +- Or set compatibility flags on game executables + +**Reversible**: N/A - informational fix only + +--- + +### ExpandedLANLobbyMenu + +**Purpose**: Provides guidance for expanded LAN lobby menu features in Generals and Zero Hour. + +**What It Does**: + +- Explains built-in LAN support in Generals and Zero Hour +- Provides step-by-step instructions for LAN play +- Lists best practices for LAN gaming +- Explains network requirements and firewall settings + +**How It Works**: + +1. Explains that LAN lobby menu is built into the game +2. Provides instructions for accessing LAN features +3. Lists network requirements +4. Provides troubleshooting tips + +**User Action Required**: + +- Ensure all players are on same network +- Launch game and go to Multiplayer > Network > LAN +- Create or join LAN game + +**Reversible**: N/A - informational fix only + +--- + +### ProxyLauncher + +**Purpose**: Provides information about GenHub's proxy-based launching system. + +**What It Does**: + +- Explains GenHub's proxy launcher architecture +- Lists benefits of proxy launcher +- Explains integration with ActionSet framework +- Explains that proxy launcher is automatically used + +**How It Works**: + +1. Explains proxy launcher architecture +2. Lists benefits: compatibility, isolation, error handling +3. Explains integration with ActionSet framework +4. Explains automatic usage when launching through GenHub + +**Benefits**: + +- Improved compatibility with modern Windows versions +- Better process isolation +- Enhanced error handling and logging +- Support for custom launch parameters +- Integration with GenHub's ActionSet framework + +**Reversible**: N/A - informational fix only + +--- + +### StartMenuFix + +**Purpose**: Creates or fixes start menu shortcuts for Generals and Zero Hour. + +**What It Does**: + +- Checks for existing shortcuts in Windows Start Menu +- Provides instructions to create shortcuts manually +- Explains how to create shortcuts through GenHub + +**How It Works**: + +1. Checks for shortcuts in Start Menu > Programs +2. Provides step-by-step instructions for manual creation +3. Explains how to create shortcuts through GenHub UI +4. Lists common shortcut names for both games + +**User Action Required**: + +- Right-click on game executable +- Select "Show more options" > "Create shortcut" +- Move shortcut to desired location +- Or use GenHub to create shortcuts + +**Reversible**: N/A - informational fix only + +--- + +### IntelGfxDriverCompatibility + +**Purpose**: Provides Intel graphics driver compatibility guidance to prevent graphics issues. + +**What It Does**: + +- Checks for Intel graphics via registry and WMI +- Checks for Intel Driver & Support Assistant installation +- Provides instructions to update Intel drivers +- Lists multiple methods to obtain latest drivers + +**How It Works**: + +1. Checks Windows registry for Intel graphics entries +2. Uses WMI to query video controllers +3. Checks for Intel Driver & Support Assistant +4. Provides step-by-step update instructions +5. Explains post-update steps + +**User Action Required**: + +- Open Intel Driver & Support Assistant +- Go to Drivers tab +- Click "Check for updates" +- Follow prompts to install latest driver +- Restart computer after update + +**Reversible**: N/A - informational fix only + +--- + +## Fix Categories + +### Automated Fixes (21) + +These fixes automatically apply changes without user intervention: + +1. BrowserEngineFix +2. DbgHelpFix +3. EAAppRegistryFix +4. MyDocumentsPathCompatibility +5. VCRedist2010Fix +6. RemoveReadOnlyFix +7. AppCompatConfigurationsFix +8. DirectXRuntimeFix +9. Patch104Fix +10. Patch108Fix +11. OptionsINIFix +12. OneDriveFix +13. EdgeScrollerFix +14. TheFirstDecadeRegistryFix +15. CNCOnlineRegistryFix +16. NetworkPrivateProfileFix +17. PreferIPv4Fix +18. FirewallExceptionFix +19. SerialKeyFix +20. CncOnlineLauncherFix +21. Patch104Fix (Official) +22. Patch108Fix (Official) + +### Network Optimization Fixes (3) + +These fixes optimize network settings for better LAN and online multiplayer: + +1. NetworkPrivateProfileFix +2. PreferIPv4Fix +3. FirewallExceptionFix + +### Informational Fixes (14) + +These fixes provide guidance and require manual user action: + +1. VanillaExecutableFix +2. ZeroHourExecutableFix +3. MalwarebytesFix +4. D3D8XDLLCheck +5. NahimicFix +6. DisableOriginInGame +7. GenArial +8. HDIconsFix +9. WindowsMediaFeaturePack +10. GameRangerRunAsAdmin +11. ExpandedLANLobbyMenu +12. ProxyLauncher +13. StartMenuFix +14. IntelGfxDriverCompatibility + +--- + +## Execution Order + +Fixes are applied in the following recommended order for optimal results: + +1. **Critical Fixes** (must be applied first): + - RemoveReadOnlyFix + - MyDocumentsPathCompatibility + - VCRedist2010Fix + - DirectXRuntimeFix + - Patch108Fix (Generals only) + - Patch104Fix (Zero Hour only) + - OptionsINIFix + +2. **Compatibility Fixes** (apply after critical fixes): + - OneDriveFix + - AppCompatConfigurationsFix + - EdgeScrollerFix + - TheFirstDecadeRegistryFix + - CNCOnlineRegistryFix + - EAAppRegistryFix + +3. **Network Optimization Fixes** (apply for better multiplayer): + - NetworkPrivateProfileFix + - PreferIPv4Fix + - FirewallExceptionFix + +4. **Optional Fixes** (apply as needed): + - BrowserEngineFix + - DbgHelpFix + - VanillaExecutableFix + - ZeroHourExecutableFix + - MalwarebytesFix + - D3D8XDLLCheck + - NahimicFix + - DisableOriginInGame + - GenArial + - HDIconsFix + - WindowsMediaFeaturePack + - GameRangerRunAsAdmin + - ExpandedLANLobbyMenu + - ProxyLauncher + - StartMenuFix + - IntelGfxDriverCompatibility + +--- + +## Technical Details + +### ActionSet Framework + +All fixes implement the `IActionSet` interface and inherit from `BaseActionSet`: + +```csharp +public interface IActionSet +{ + string Id { get; } + string Title { get; } + string Description { get; } + bool IsCoreFix { get; } + bool IsCrucialFix { get; } + + Task IsApplicableAsync(GameInstallation installation); + Task IsAppliedAsync(GameInstallation installation); + Task ApplyAsync(GameInstallation installation, IProgress? progress, CancellationToken ct); + Task UndoAsync(GameInstallation installation, IProgress? progress, CancellationToken ct); +} +``` + +### Result Pattern + +All fixes return `ActionSetResult` with the following structure: + +```csharp +public record ActionSetResult(bool Success, string? ErrorMessage = null); +``` + +- `Success`: Indicates whether the fix was applied successfully +- `ErrorMessage`: Optional error message if the fix failed + +### Dependency Injection + +All fixes are registered as singletons in the DI container: + +```csharp +services.AddSingleton(); +services.AddSingleton(); +// ... etc +``` + +### Game Installation Model + +Fixes receive a `GameInstallation` object containing: + +```csharp +public class GameInstallation +{ + public bool HasGenerals { get; } + public bool HasZeroHour { get; } + public string GeneralsPath { get; } + public string ZeroHourPath { get; } + // ... other properties +} +``` + +--- + +## Common Patterns + +### File Replacement Pattern + +Used by fixes that replace files (e.g., DbgHelpFix): + +1. Check if target file exists +2. Backup original file to `.bak` +3. Copy/extract new file +4. Verify new file exists +5. For undo: restore from backup + +### Registry Fix Pattern + +Used by fixes that modify registry (e.g., EAAppRegistryFix): + +1. Check if key/value exists +2. Read current value (for undo) +3. Write new value +4. Verify write succeeded +5. Store original value for undo + +### INI File Pattern + +Used by fixes that modify INI files (e.g., OptionsINIFix): + +1. Load INI file using `IGameSettingsService` +2. Apply optimal settings +3. Save modified INI file +4. For undo: restore original values + +### Download and Install Pattern + +Used by fixes that download and install software (e.g., VCRedist2010Fix): + +1. Check if software is already installed +2. Download installer to temp directory +3. Execute with silent flags +4. Wait for completion +5. Verify installation +6. Clean up temp files + +--- + +## Troubleshooting + +### Fix Not Applying + +If a fix fails to apply: + +1. Check the logs for detailed error messages +2. Ensure you have administrator privileges +3. Verify game installation paths are correct +4. Check that required dependencies are installed +5. Try running the fix again + +### Fix Cannot Be Undone + +Some fixes cannot be undone: + +- Official patches (Patch104Fix, Patch108Fix) +- Software installations (VCRedist2010Fix, DirectXRuntimeFix) +- Folder creation (MyDocumentsPathCompatibility) + +### Informational Fixes + +Informational fixes provide guidance but don't make changes: + +- Check the logs for detailed instructions +- Follow the step-by-step guidance provided +- Some fixes require manual configuration in third-party software + +--- + +## References + +- [ActionSet Framework Documentation](../dev/result-pattern.md) +- [Game Settings Documentation](game-settings.md) +- [Content System Documentation](content.md) +- [Coding Style Guide](../dev/coding-style.md) diff --git a/docs/tools/replay-manager.md b/docs/tools/replay-manager.md index 6e031b127..a05d4d829 100644 --- a/docs/tools/replay-manager.md +++ b/docs/tools/replay-manager.md @@ -13,20 +13,28 @@ The Replay Manager is a built-in tool in GenHub that allows you to manage, impor ## Getting Started To access the Replay Manager: + 1. Open GenHub. + 2. Navigate to the **TOOLS** tab. 3. Select **Replay Manager** from the sidebar. ## Importing Replays ### From URL + + You can import replays from various sources by pasting the link into the import bar: + - **UploadThing**: Direct links from other GenHub users. + - **Generals Online**: Match view URLs. - **GenTool**: Directory URLs from the GenTool data repository. - **Direct Links**: Any URL ending in `.rep` or `.zip`. ### Drag and Drop + + Simply drag one or more `.rep` or `.zip` files from your computer and drop them anywhere on the Replay Manager window to import them. ## Sharing Replays @@ -42,7 +50,9 @@ Simply drag one or more `.rep` or `.zip` files from your computer and drop them ## Storage Policy Replays imported or shared via GenHub are subject to the following policies: + - Files are maintained in our cloud storage for **14 days**. + - Older files may be removed automatically to make room for new ones. - Only `.rep` files are allowed within shared archives.