diff --git a/GenHub/GenHub.Core/Constants/ActionSetConstants.cs b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs
new file mode 100644
index 000000000..aca93073d
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/ActionSetConstants.cs
@@ -0,0 +1,396 @@
+namespace GenHub.Core.Constants;
+
+using System.Collections.Generic;
+using System.IO;
+
+///
+/// Centralized constants for ActionSet fixes, registry keys, and file operations.
+///
+public static class ActionSetConstants
+{
+ // RegistryKeys moved to GenHub.Core.Constants.RegistryConstants.cs
+
+ ///
+ /// File names and content.
+ ///
+ public static class FileNames
+ {
+ ///
+ /// Gets the desktop.ini file name used for folder customization.
+ ///
+ public const string DesktopIni = "desktop.ini";
+
+ ///
+ /// Gets the Generals.exe file name.
+ ///
+ public const string GeneralsExe = "generals.exe";
+
+ ///
+ /// Gets the Game.dat file name.
+ ///
+ public const string GameDat = "Game.dat";
+
+ ///
+ /// Gets the game.exe file name, often used for Zero Hour.
+ ///
+ public const string GameExe = "game.exe"; // Often used for ZH
+
+ ///
+ /// Gets the DXSETUP.exe file name used for DirectX runtime installer.
+ ///
+ public const string DxSetupExe = "DXSETUP.exe";
+ }
+
+ ///
+ /// Initialization file sections and keys.
+ ///
+ public static class IniFiles
+ {
+ // Sections
+
+ ///
+ /// Gets the [.ShellClassInfo] section name for desktop.ini files.
+ ///
+ public const string ShellClassInfoSection = "[.ShellClassInfo]";
+
+ ///
+ /// Gets the TheSuperHackers section name for Options.ini files.
+ ///
+ public const string TheSuperHackersSection = "TheSuperHackers";
+
+ // Keys
+
+ ///
+ /// Gets the ThisPCPolicy key name used to disable OneDrive sync.
+ ///
+ public const string ThisPCPolicyKey = "ThisPCPolicy";
+
+ ///
+ /// Gets the ThisPCPolicy value to disable OneDrive cloud sync.
+ ///
+ public const string ThisPCPolicyValue = "DisableCloudSync";
+
+ ///
+ /// Gets the ConfirmFileOp key name used in desktop.ini files.
+ ///
+ public const string ConfirmFileOpKey = "ConfirmFileOp";
+
+ // TheSuperHackers keys
+
+ ///
+ /// Gets the ScrollEdgeZone key name for edge scrolling settings.
+ ///
+ public const string ScrollEdgeZoneKey = "ScrollEdgeZone";
+
+ ///
+ /// Gets the ScrollEdgeSpeed key name for edge scrolling settings.
+ ///
+ public const string ScrollEdgeSpeedKey = "ScrollEdgeSpeed";
+
+ ///
+ /// Gets the ScrollEdgeAcceleration key name for edge scrolling settings.
+ ///
+ public const string ScrollEdgeAccelerationKey = "ScrollEdgeAcceleration";
+
+ ///
+ /// Gets the ScrollFactor key name for edge scrolling settings.
+ ///
+ public const string ScrollFactorKey = "ScrollFactor";
+ }
+
+ ///
+ /// ActionSet category constants.
+ ///
+ public static class Categories
+ {
+ ///
+ /// Gets the All category filter option.
+ ///
+ public const string All = "All";
+
+ ///
+ /// Gets the Core & Stability category.
+ ///
+ public const string CoreAndStability = "Core & Stability";
+
+ ///
+ /// Gets the Compatibility category.
+ ///
+ public const string Compatibility = "Compatibility";
+
+ ///
+ /// Gets the Multiplayer category.
+ ///
+ public const string Multiplayer = "Multiplayer";
+
+ ///
+ /// Gets the Quality of Life category.
+ ///
+ public const string QualityOfLife = "Quality of Life";
+ }
+
+ ///
+ /// Firewall rule names and protocols.
+ ///
+ public static class FirewallRules
+ {
+ ///
+ /// Gets the prefix used for firewall rule names for GenPatcher compatibility.
+ ///
+ public const string Prefix = "GP"; // Compatibility with GenPatcher
+
+ ///
+ /// Gets the firewall rule name for UDP port 16000.
+ ///
+ public const string PortRuleUdp16000 = "GP Open UDP Port 16000";
+
+ ///
+ /// Gets the firewall rule name for UDP port 16001.
+ ///
+ public const string PortRuleUdp16001 = "GP Open UDP Port 16001";
+
+ ///
+ /// Gets the firewall rule name for TCP port 16001.
+ ///
+ public const string PortRuleTcp16001 = "GP Open TCP Port 16001";
+
+ ///
+ /// Gets the firewall rule name for Generals.exe.
+ ///
+ public const string GeneralsRule = "GP Command & Conquer Generals";
+
+ ///
+ /// Gets the firewall rule name for Generals Game.dat.
+ ///
+ public const string GeneralsGameDatRule = "GP Command & Conquer Generals Game.dat";
+
+ ///
+ /// Gets the firewall rule name for Zero Hour.
+ ///
+ public const string ZeroHourRule = "GP Command & Conquer Generals Zero Hour";
+
+ ///
+ /// Gets the firewall rule name for Zero Hour Game.dat.
+ ///
+ public const string ZeroHourGameDatRule = "GP Command & Conquer Generals Zero Hour Game.dat";
+
+ ///
+ /// Gets the UDP protocol string.
+ ///
+ public const string ProtocolUdp = "UDP";
+
+ ///
+ /// Gets the TCP protocol string.
+ ///
+ public const string ProtocolTcp = "TCP";
+ }
+
+ ///
+ /// Constants for Malwarebytes detection and paths.
+ ///
+ public static class Malwarebytes
+ {
+ ///
+ /// Gets the registry uninstall key path for detecting Malwarebytes.
+ ///
+ public const string RegistryUninstallKey = RegistryConstants.UninstallKeyPath;
+
+ ///
+ /// Gets the DisplayName value name in the registry.
+ ///
+ public const string DisplayNameValue = RegistryConstants.DisplayNameValueName;
+
+ ///
+ /// Gets the string to check for in DisplayName to identify Malwarebytes.
+ ///
+ public const string NameContains = "Malwarebytes";
+
+ ///
+ /// Gets the array of executable paths for Malwarebytes applications.
+ ///
+ public static readonly IReadOnlyList ExecutablePaths =
+ [
+ Path.Combine("Malwarebytes", "Anti-Malware", "mbam.exe"),
+ Path.Combine("Malwarebytes", "Anti-Malware", "mbamtray.exe")
+ ];
+ }
+
+ ///
+ /// File and directory paths used by ActionSets.
+ ///
+ public static class Paths
+ {
+ ///
+ /// Gets the directory name for sub-action set markers.
+ ///
+ public const string SubActionSetMarkers = "sub_markers";
+
+ ///
+ /// Gets the marker file name for remove read-only fix.
+ ///
+ public const string ReadOnlyFixMarker = ".gp_ro_fix";
+ }
+
+ ///
+ /// Default serial keys used for fallback generation.
+ ///
+ public static class Serials
+ {
+ ///
+ /// Default placeholder serial for Generals EA App installations.
+ ///
+ public const string DefaultEAAppGeneralsSerial = "GENS1234567890ABCDEF";
+
+ ///
+ /// Default placeholder serial for Zero Hour EA App installations.
+ ///
+ public const string DefaultEAAppZeroHourSerial = "ZH1234567890ABCDEFGH";
+ }
+
+ ///
+ /// UI status badge colors.
+ ///
+ public static class StatusColors
+ {
+ /// Hex color for applied state.
+ public const string Applied = "#28a745";
+
+ /// Hex color for unapplied state.
+ public const string Unapplied = "#ffc107";
+
+ /// Hex color for not applicable state.
+ public const string NotApplicable = "#6c757d";
+
+ /// Hex color for checking state.
+ public const string Checking = "#17a2b8";
+
+ /// Hex color for error state.
+ public const string Error = "#dc3545";
+
+ /// Hex background color for applied state badge.
+ public const string AppliedBackground = "#2228A745";
+
+ /// Hex background color for unapplied state badge.
+ public const string UnappliedBackground = "#22FFC107";
+
+ /// Hex background color for not applicable state badge.
+ public const string NotApplicableBackground = "#156c757d";
+
+ /// Hex border color for applied state badge.
+ public const string AppliedBorder = "#4428A745";
+
+ /// Hex border color for unapplied state badge.
+ public const string UnappliedBorder = "#44FFC107";
+
+ /// Hex border color for not applicable state badge.
+ public const string NotApplicableBorder = "#256c757d";
+ }
+
+ ///
+ /// Validation constants for file operations.
+ ///
+ public static class Validation
+ {
+ ///
+ /// Minimum file size for VCRedist installers (1000 KB).
+ ///
+ public const long VCRedistMinSize = 1000 * 1024;
+
+ ///
+ /// Minimum file size for DirectX web setup installer (200 KB).
+ ///
+ public const long DirectXWebSetupMinSize = 200 * 1024;
+
+ ///
+ /// Minimum file size for DirectX runtime ZIP package (1 MB).
+ ///
+ public const long DirectXPackageMinSize = 1024 * 1024;
+
+ ///
+ /// Minimum file size for patch archives and installers (1 MB).
+ ///
+ public const long PatchMinSize = 1024 * 1024;
+
+ ///
+ /// Minimum file size for GenTool archive (200 KB).
+ ///
+ public const long GenToolMinSize = 200 * 1024;
+
+ ///
+ /// Minimum file size for addon packages like custom windows and high-definition icons (1 KB).
+ ///
+ public const long MinimumAddonPackageSizeBytes = 1024;
+
+ ///
+ /// Maximum file size for addon packages like custom windows and high-definition icons (200 MB).
+ ///
+ public const long MaximumAddonPackageSizeBytes = 200 * 1024 * 1024;
+ }
+
+ ///
+ /// Security constants for digital signature and Authenticode publisher validation.
+ ///
+ public static class Security
+ {
+ ///
+ /// Gets the expected Microsoft Corporation Authenticode publisher string.
+ ///
+ public const string MicrosoftPublisher = "Microsoft Corporation";
+
+ ///
+ /// Gets the expected Electronic Arts Authenticode publisher string.
+ ///
+ public const string ElectronicArtsPublisher = "Electronic Arts";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the Generals 1.08 patch archive.
+ ///
+ public const string Generals108PatchSha256 = "265ff414850ef92e94828508f849a363c7fbe994d6994c6405e9eeaaa0f6b5c5";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the DirectX runtime ZIP archive.
+ ///
+ public const string DirectXRuntimeZipSha256 = "6fcc7cd1be32422d07f022424412d6fe3141c6ba3845b855cb6f1b18f9c3a0a7";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the GenTool archive package.
+ ///
+ public const string GenToolArchiveSha256 = "62bb0380ae14c570b6fad92b31784bec188dc22ac5ac9e11d3c524e08fa434e4";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the GenTool d3d8.dll binary.
+ ///
+ public const string GenToolD3D8DllSha256 = "be5276180d04b3de9abd20aeaf2c1f65a2b65c800233ce49d5e77f1ab42441f7";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the Expanded LAN Lobby / Custom Windows cbbs.dat package.
+ ///
+ public const string ExpandedLANLobbySha256 = "41f4c65c89bfae958d593a841b7f77aa6737cd12f810f5a3903a0a4cd6f7482d";
+
+ ///
+ /// Gets the pinned SHA-256 hash for the High-Definition Icons icon.dat package.
+ ///
+ public const string HDIconsSha256 = "68aedc84b0c4291dee7bdd079c551273e33cee4026ecc482ab48850cf99f7baa";
+ }
+
+ ///
+ /// Constants for confirmation and notification dialogs.
+ ///
+ public static class Dialogs
+ {
+ ///
+ /// Gets the title for the Apply All recommended fixes confirmation dialog.
+ ///
+ public const string ApplyAllConfirmationTitle = "Apply All Recommended Fixes";
+
+ ///
+ /// Gets the confirmation button text for the Apply All dialog.
+ ///
+ public const string ApplyAllConfirmButtonText = "Apply Fixes";
+
+ ///
+ /// Gets the cancel button text for the Apply All dialog.
+ ///
+ public const string ApplyAllCancelButtonText = "Cancel";
+ }
+}
diff --git a/GenHub/GenHub.Core/Constants/ExternalUrls.cs b/GenHub/GenHub.Core/Constants/ExternalUrls.cs
new file mode 100644
index 000000000..abe8965c2
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/ExternalUrls.cs
@@ -0,0 +1,108 @@
+namespace GenHub.Core.Constants;
+
+using System.Diagnostics.CodeAnalysis;
+
+///
+/// Constants for external URLs used for downloading dependencies or tools.
+///
+[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized static URL constants repository")]
+public static class ExternalUrls
+{
+ ///
+ /// Download URL for Visual C++ 2010 Redistributable Package (x86).
+ /// Required for Generals and Zero Hour to run.
+ ///
+ public const string VCRedist2010DownloadUrl = "https://download.microsoft.com/download/1/6/5/165255E7-1014-4D0A-B094-B6A430A6BFFC/vcredist_x86.exe";
+
+ ///
+ /// Gets the primary download URL for DirectX runtime (Microsoft Official).
+ ///
+ public const string DirectXRuntimeDownloadUrlPrimary = "https://download.microsoft.com/download/1/7/1/1718CCC4-6315-4D8E-9543-8E28A4E18C4C/dxwebsetup.exe";
+
+ ///
+ /// Gets the secondary download URL for DirectX runtime (Gentool).
+ ///
+ public const string DirectXRuntimeDownloadUrlMirror1 = "https://gentool.net/program_data/genpatcher/drtx.dat";
+
+ ///
+ /// Download URL for Generals 1.08 official patch.
+ ///
+ public const string Generals108PatchUrl = "https://gentool.net/program_data/genpatcher/10gn.dat";
+
+ ///
+ /// Gets the primary download URL for Zero Hour 1.04 patch (CNCNZ).
+ ///
+ public const string ZeroHour104PatchUrlPrimary = "https://http.cncnz.com/patches/GeneralsZH-104-english.exe";
+
+ ///
+ /// Gets the secondary download URL for Zero Hour 1.04 patch (Gentool).
+ ///
+ public const string ZeroHour104PatchUrlMirror1 = "https://gentool.net/program_data/genpatcher/10zh.dat";
+
+ ///
+ /// Gets the primary download URL for GenTool (Gentool).
+ ///
+ public const string GenToolDownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/gent.dat";
+
+ ///
+ /// Gets the secondary download URL for GenTool (Legi.cc).
+ ///
+ public const string GenToolDownloadUrlMirror1 = "https://legi.cc/gp2/f/gent.dat";
+
+ ///
+ /// Gets the primary download URL for High-Definition Icons (Legi.cc).
+ ///
+ public const string HDIconsDownloadUrlPrimary = "https://legi.cc/gp2/f/icon.dat";
+
+ ///
+ /// Gets the primary download URL for Expanded LAN Lobby Menu & Custom Windows (Gentool).
+ ///
+ public const string ExpandedLANLobbyDownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/cbbs.dat";
+
+ ///
+ /// Gets the secondary download URL for Expanded LAN Lobby Menu & Custom Windows (Legi.cc).
+ ///
+ public const string ExpandedLANLobbyDownloadUrlMirror1 = "https://legi.cc/gp2/f/cbbs.dat";
+
+ ///
+ /// Gets the primary download URL for Visual C++ 2005 Redistributable (Gentool).
+ ///
+ public const string VCRedist2005DownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/vcredist_x86-2005.exe";
+
+ ///
+ /// Gets the secondary download URL for Visual C++ 2005 Redistributable (Legi.cc).
+ ///
+ public const string VCRedist2005DownloadUrlMirror1 = "https://legi.cc/gp2/f/vc05.dat";
+
+ ///
+ /// Gets the primary download URL for Visual C++ 2008 Redistributable (Gentool).
+ ///
+ public const string VCRedist2008DownloadUrlPrimary = "https://gentool.net/program_data/genpatcher/vcredist_x86-2008.exe";
+
+ ///
+ /// Gets the secondary download URL for Visual C++ 2008 Redistributable (Legi.cc).
+ ///
+ public const string VCRedist2008DownloadUrlMirror1 = "https://legi.cc/gp2/f/vc08.dat";
+
+ // Legacy support
+
+ ///
+ /// Legacy download URL for DirectX runtime.
+ ///
+ public const string DirectXRuntimeDownloadUrl = DirectXRuntimeDownloadUrlPrimary;
+
+ ///
+ /// Legacy download URL for Zero Hour 1.04 patch.
+ ///
+ public const string ZeroHour104PatchUrl = ZeroHour104PatchUrlPrimary;
+
+ ///
+ /// Download URL for Intel Graphics Drivers.
+ ///
+ public const string IntelDriverDownloadUrl = "https://www.intel.com/content/www/us/en/download-center/home";
+
+ ///
+ /// Support URL for Windows Media Feature Pack.
+ ///
+ public const string WindowsMediaFeaturePackSupportUrl = "https://support.microsoft.com/en-us/windows/media-feature-pack-for-windows-10-11-n-and-kn-editions-8007a829-873b-e0cf-dd4e-9d2fa7848fbb";
+}
diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
index 6937d2e2c..d9b304752 100644
--- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs
@@ -147,6 +147,18 @@ public static class GameClientConstants
///
public const string ZeroHourShortName = "Zero Hour";
+ /// BrowserEngine.dll filename.
+ public const string BrowserEngineDll = "BrowserEngine.dll";
+
+ /// BrowserEngine.dll backup filename.
+ public const string BrowserEngineDllBak = "BrowserEngine.dll.bak";
+
+ /// dbghelp.dll filename.
+ public const string DbgHelpDll = "dbghelp.dll";
+
+ /// dbghelp.dll backup filename.
+ public const string DbgHelpDllBak = "dbghelp.dll.bak";
+
///
/// DLLs required for standard game installations.
///
diff --git a/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs
index 0aa9fd77a..371f311eb 100644
--- a/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameSettingsConstants.cs
@@ -159,10 +159,31 @@ public static class FolderNames
///
public const string Replays = "Replays";
+ ///
+ /// Folder name for Command and Conquer Generals German settings.
+ ///
+ public const string GeneralsGerman = "Command and Conquer Generals Daten";
+
+ ///
+ /// Folder name for Command and Conquer Generals Zero Hour German settings.
+ ///
+ public const string ZeroHourGerman = "Command and Conquer Generals Zero Hour Daten";
+
///
/// Subfolder name for screenshots within the game data directory.
///
public const string Screenshots = "Screenshots";
+
+ ///
+ /// All known user data folder names for Generals and Zero Hour (including localized variants).
+ ///
+ public static readonly IReadOnlyList AllUserDataFolderNames =
+ [
+ Generals,
+ ZeroHour,
+ GeneralsGerman,
+ ZeroHourGerman,
+ ];
}
///
@@ -203,4 +224,209 @@ public static class ResolutionPresets
"7680x4320", // 8K
];
}
+
+ ///
+ /// Optimal settings for game performance and compatibility.
+ ///
+ public static class OptimalSettings
+ {
+ // Video
+
+ ///
+ /// Gets the optimal anti-aliasing value (1 = 2x).
+ ///
+ public const int AntiAliasing = 1;
+
+ ///
+ /// Gets the optimal texture reduction value (0 = no reduction).
+ ///
+ public const int TextureReduction = 0;
+
+ ///
+ /// Gets a value indicating whether extra animations are enabled.
+ ///
+ public const bool ExtraAnimations = true;
+
+ ///
+ /// Gets the optimal gamma correction value (50 = neutral).
+ ///
+ public const int OptimalGamma = 50;
+
+ ///
+ /// Gets a value indicating whether shadow decals are enabled.
+ ///
+ public const bool UseShadowDecals = true;
+
+ ///
+ /// Gets a value indicating whether shadow volumes are enabled.
+ ///
+ public const bool UseShadowVolumes = false;
+
+ ///
+ /// Gets a value indicating whether windowed mode is enabled.
+ ///
+ public const bool Windowed = false;
+
+ ///
+ /// Gets the optimal default resolution width (1920).
+ ///
+ public const int DefaultResolutionWidth = 1920;
+
+ ///
+ /// Gets the optimal default resolution height (1080).
+ ///
+ public const int DefaultResolutionHeight = 1080;
+
+ // Audio
+
+ ///
+ /// Gets the optimal volume level (70), common for SFX, Music, and Voice.
+ ///
+ public const int VolumeLevel = 70; // Common for SFX, Music, Voice
+
+ ///
+ /// Gets a value indicating whether audio is enabled.
+ ///
+ public const bool AudioEnabled = true;
+
+ ///
+ /// Gets the optimal number of sounds (16).
+ ///
+ public const int NumSounds = 16;
+
+ // Network
+
+ ///
+ /// Gets the optimal GameSpy IP address (0.0.0.0 for local).
+ ///
+ public const string GameSpyIPAddress = "0.0.0.0";
+
+ // TheSuperHackers
+
+ ///
+ /// Gets the building occlusion setting ("yes").
+ ///
+ public const string BuildingOcclusion = "yes";
+
+ ///
+ /// Gets the campaign difficulty setting ("0").
+ ///
+ public const string CampaignDifficulty = "0";
+
+ ///
+ /// Gets the dynamic LOD setting ("no").
+ ///
+ public const string DynamicLOD = "no";
+
+ ///
+ /// Gets the firewall port override setting ("16001").
+ ///
+ public const string FirewallPortOverride = "16001";
+
+ ///
+ /// Gets the heat effects setting ("no").
+ ///
+ public const string HeatEffects = "no";
+
+ ///
+ /// Gets the ideal static game LOD setting ("High").
+ ///
+ public const string IdealStaticGameLOD = "High";
+
+ ///
+ /// Gets the language filter setting ("false").
+ ///
+ public const string LanguageFilter = "false";
+
+ ///
+ /// Gets the max particle count setting ("1000").
+ ///
+ public const string MaxParticleCount = "1000";
+
+ ///
+ /// Gets the retaliation setting ("yes").
+ ///
+ public const string Retaliation = "yes";
+
+ ///
+ /// Gets the scroll factor setting ("60").
+ ///
+ public const string ScrollFactor = "60";
+
+ ///
+ /// Gets the send delay setting ("no").
+ ///
+ public const string SendDelay = "no";
+
+ ///
+ /// Gets the show soft water edge setting ("yes").
+ ///
+ public const string ShowSoftWaterEdge = "yes";
+
+ ///
+ /// Gets the show trees setting ("yes").
+ ///
+ public const string ShowTrees = "yes";
+
+ ///
+ /// Gets the static game LOD setting ("Custom").
+ ///
+ public const string StaticGameLOD = "Custom";
+
+ ///
+ /// Gets the use alternate mouse setting ("no").
+ ///
+ public const string UseAlternateMouse = "no";
+
+ ///
+ /// Gets the use cloud map setting ("yes").
+ ///
+ public const string UseCloudMap = "yes";
+
+ ///
+ /// Gets the use double click attack move setting ("no").
+ ///
+ public const string UseDoubleClickAttackMove = "no";
+
+ ///
+ /// Gets the use light map setting ("yes").
+ ///
+ public const string UseLightMap = "yes";
+
+ ///
+ /// Gets the scroll edge zone setting ("0").
+ ///
+ public const string ScrollEdgeZone = "0";
+
+ ///
+ /// Gets the scroll edge speed setting ("1.0").
+ ///
+ public const string ScrollEdgeSpeed = "1.0";
+
+ ///
+ /// Gets the scroll edge acceleration setting ("0.0").
+ ///
+ public const string ScrollEdgeAcceleration = "0.0";
+ }
+
+ ///
+ /// Problematic resolutions that crash or distort Generals/Zero Hour.
+ ///
+ public static class ProblematicResolutions
+ {
+ ///
+ /// Gets the list of problematic resolution pairs (width, height).
+ ///
+ public static readonly IReadOnlyList<(int Width, int Height)> KnownBadResolutions =
+ [
+ (0, 0),
+ (320, 240),
+ (400, 300),
+ (512, 384),
+ (640, 480),
+ (1366, 768),
+ (1360, 768),
+ (1280, 768),
+ ];
+ }
}
diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs
index d4d7187ff..c768db359 100644
--- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs
@@ -34,6 +34,16 @@ public static class ProcessConstants
///
public const int ExitCodeAccessDenied = 5;
+ ///
+ /// Exit code indicating success with reboot required (Windows Installer standard).
+ ///
+ public const int ExitCodeRebootRequired = 3010;
+
+ ///
+ /// PowerShell executable name.
+ ///
+ public const string PowerShellExecutable = "powershell.exe";
+
// Windows API constants
///
diff --git a/GenHub/GenHub.Core/Constants/RegistryConstants.cs b/GenHub/GenHub.Core/Constants/RegistryConstants.cs
new file mode 100644
index 000000000..f051a40a0
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/RegistryConstants.cs
@@ -0,0 +1,177 @@
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for Windows Registry keys and values.
+///
+public static class RegistryConstants
+{
+ // ===== EA App / Origin Keys =====
+
+ /// Registry key path for Generals command and conquer.
+ public const string EAAppGeneralsKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Generals";
+
+ /// Registry key path for Zero Hour.
+ public const string EAAppZeroHourKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour";
+
+ /// Registry key path for Generals Ergc (Serial).
+ public const string EAAppGeneralsErgcKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Generals\ergc";
+
+ /// Registry key path for Zero Hour Ergc (Serial).
+ public const string EAAppZeroHourErgcKeyPath = @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour\ergc";
+
+ // ===== VCRedist Keys =====
+
+ /// Squished (compressed) GUID for Visual C++ 2005 Redistributable x86.
+ public const string VCRedist2005SquishedGuid = "b25099274a207264182f8181add555d0";
+
+ /// Registry key for VCRedist 2005 in Installer UserData Products.
+ public const string VCRedist2005InstallerProductsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\" + VCRedist2005SquishedGuid;
+
+ /// Registry key for VCRedist 2005 in WOW6432Node Installer UserData Products.
+ public const string VCRedist2005InstallerProductsKeyWow64 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\" + VCRedist2005SquishedGuid;
+
+ /// Registry key for VCRedist 2005 in Classes Installer Products.
+ public const string VCRedist2005ClassesKey = @"SOFTWARE\Classes\Installer\Products\" + VCRedist2005SquishedGuid;
+
+ /// Registry key for VCRedist 2010 x86 (32-bit).
+ public const string VCRedist2010x86Key = @"SOFTWARE\Microsoft\VisualStudio\10.0\VC\VCRedist\x86";
+
+ /// Registry key for VCRedist 2010 x86 (64-bit environment / WOW6432Node).
+ public const string VCRedist2010x86KeyWow64 = @"SOFTWARE\WOW6432Node\Microsoft\VisualStudio\10.0\VC\VCRedist\x86";
+
+ // ===== Value Names =====
+
+ /// Registry value name for 'Install Path'.
+ public const string InstallPathValueName = "Install Path";
+
+ /// Registry value name for 'Version'.
+ public const string VersionValueName = "Version";
+
+ /// Registry value name for 'Installed'.
+ public const string InstalledValueName = "Installed";
+
+ // ===== Registry Versions (DWORD) =====
+
+ /// Registry version for Generals 1.08 (0x10008).
+ public const int GeneralsVersionDWord = 0x10008;
+
+ /// Registry version for Zero Hour 1.04 (0x10004).
+ public const int ZeroHourVersionDWord = 0x10004;
+
+ // ===== Windows System Keys =====
+
+ /// Registry key path for Windows Compatibility Flags (AppCompatLayers).
+ public const string AppCompatLayersKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers";
+
+ // ===== The First Decade Keys =====
+
+ /// Registry key path for The First Decade.
+ public const string TheFirstDecadeKeyPath = @"SOFTWARE\EA Games\Command & Conquer The First Decade";
+
+ /// The First Decade registry version data string ("1.03").
+ public const string TfdVersionData = "1.03";
+
+ /// Registry value data for TFD Version (alias for backward compatibility).
+ public const string TfdVersionValue = TfdVersionData;
+
+ // ===== C&C Online (Revora) Keys =====
+
+ /// Registry key path for C&C Online (Root).
+ public const string CncOnlineKeyPath = @"SOFTWARE\Revora\CNCOnline";
+
+ /// Registry key path for C&C Online Generals.
+ public const string CncOnlineGeneralsKeyPath = @"SOFTWARE\Revora\CNCOnline\Generals";
+
+ /// Registry key path for C&C Online Zero Hour.
+ public const string CncOnlineZeroHourKeyPath = @"SOFTWARE\Revora\CNCOnline\ZeroHour";
+
+ /// C&C Online Version.
+ public const string CncOnlineVersion = "1.0";
+
+ /// C&C Online Generals Version.
+ public const string CncOnlineGeneralsVersion = "1.08";
+
+ /// C&C Online Zero Hour Version.
+ public const string CncOnlineZeroHourVersion = "1.04";
+
+ // ===== Malwarebytes Keys =====
+
+ /// Registry key path for Uninstall (used for detection).
+ public const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
+
+ /// Registry value name for DisplayName.
+ public const string DisplayNameValueName = "DisplayName";
+
+ // ===== Intel Graphics Keys =====
+
+ /// Registry key path for Intel Graphics Class.
+ public const string IntelGraphicsClassKeyPath = @"SYSTEM\CurrentControlSet\Control\Class\{4D36E968-E325-11CE-BFC1-08002BE10318}";
+
+ /// Registry key path for Intel MEWiz.
+ public const string IntelMEWizKeyPath = @"SOFTWARE\Intel\MEWiz1.0";
+
+ // ===== Windows Media Feature Pack =====
+
+ /// Registry key path for Windows Media Player Feature.
+ public const string WindowsMediaPlayerFeatureKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\WindowsFeatures\WindowsMediaPlayer";
+
+ // ===== Origin Keys =====
+
+ /// Registry key path for Origin.
+ public const string OriginKeyPath = @"SOFTWARE\Origin";
+
+ /// Registry key path for Origin in WOW6432Node.
+ public const string OriginKeyPathWow64 = @"SOFTWARE\WOW6432Node\Origin";
+
+ /// Registry value name for Origin Client Path.
+ public const string OriginClientPathValue = "ClientPath";
+
+ // ===== WOW64 Uninstall Key =====
+
+ /// Registry key path for 32-bit Uninstall under WOW64.
+ public const string UninstallKeyPathWow64 = @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall";
+
+ // ===== TCP/IP IPv6 Parameters =====
+
+ /// Registry key path for TCPIP6 Parameters.
+ public const string Tcpip6ParametersKeyPath = @"SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters";
+
+ /// Registry value name for DisabledComponents.
+ public const string DisabledComponentsValueName = "DisabledComponents";
+
+ /// Registry DWORD value for Prefer IPv4 over IPv6 (0x20 = 32).
+ public const int PreferIPv4DisabledComponentsValue = 32;
+
+ // ===== Fonts =====
+
+ /// Registry key path for Windows Fonts.
+ public const string FontsKeyPath = @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts";
+
+ /// Registry font value name for Arial TrueType font.
+ public const string ArialFontValueName = "Arial (TrueType)";
+
+ // ===== Component Based Servicing (CBS) =====
+
+ /// Registry key path for CBS Packages.
+ public const string CbsPackagesKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\Packages";
+
+ /// Registry value name for CBS InstallState.
+ public const string InstallStateValueName = "InstallState";
+
+ /// CBS InstallState: Installed (7).
+ public const int CbsInstallStateInstalled = 7;
+
+ /// CBS InstallState: Staged (112).
+ public const int CbsInstallStateStaged = 112;
+
+ /// CBS InstallState: Superseded (128).
+ public const int CbsInstallStateSuperseded = 128;
+
+ // ===== WMI Constants =====
+
+ /// WMI Scope for CIMV2.
+ public const string WmiScopeCimV2 = @"root\CIMV2";
+
+ /// WMI Query for Video Controller.
+ public const string WmiQueryVideoController = "SELECT * FROM Win32_VideoController";
+}
diff --git a/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs
new file mode 100644
index 000000000..1dc61950f
--- /dev/null
+++ b/GenHub/GenHub.Core/Features/ActionSets/ActionSetOrchestrator.cs
@@ -0,0 +1,288 @@
+namespace GenHub.Core.Features.ActionSets;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Core.Models.Results;
+using Microsoft.Extensions.Logging;
+
+///
+/// Implementation of the ActionSet orchestrator.
+///
+/// The initial collection of action sets.
+/// The collection of action set providers.
+/// The logger instance.
+public class ActionSetOrchestrator(
+ IEnumerable actionSets,
+ IEnumerable providers,
+ ILogger logger) : IActionSetOrchestrator
+{
+ private enum ExecutionOutcome
+ {
+ Success,
+ Skipped,
+ FailedNonCritical,
+ FailedCritical,
+ }
+
+ private readonly IReadOnlyList _actionSets = InitializeActionSets(actionSets, providers, logger);
+
+ ///
+ public IReadOnlyList GetAllActionSets() => _actionSets;
+
+ ///
+ public async Task> GetApplicableCoreFixesAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ var applicable = new List();
+ foreach (var actionSet in _actionSets.Where(x => x.IsCoreFix))
+ {
+ ct.ThrowIfCancellationRequested();
+ try
+ {
+ if (await actionSet.IsApplicableAsync(installation, ct))
+ {
+ applicable.Add(actionSet);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking applicability for {Title}", actionSet.Title);
+ }
+ }
+
+ return applicable;
+ }
+
+ ///
+ public async Task> ApplyActionSetsAsync(
+ GameInstallation installation,
+ IEnumerable actionSets,
+ CancellationToken ct = default)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ int successCount = 0;
+ var errors = new List();
+ var actionSetsList = actionSets.ToList();
+ int totalCount = actionSetsList.Count;
+
+ logger.LogInformation("Starting to apply {TotalCount} action sets to {Installation}", totalCount, installation.InstallationPath);
+
+ for (int i = 0; i < actionSetsList.Count; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var outcome = await ProcessActionSetAsync(
+ actionSetsList[i],
+ installation,
+ i + 1,
+ totalCount,
+ errors,
+ ct);
+
+ if (outcome == ExecutionOutcome.Success)
+ {
+ successCount++;
+ }
+ else if (outcome == ExecutionOutcome.FailedCritical)
+ {
+ return OperationResult.CreateFailure(errors, successCount, stopwatch.Elapsed);
+ }
+ }
+
+ stopwatch.Stop();
+ logger.LogInformation(
+ "Finished applying action sets. Success: {SuccessCount}/{TotalCount}, Errors: {ErrorCount}",
+ successCount,
+ totalCount,
+ errors.Count);
+
+ if (errors.Count > 0)
+ {
+ return OperationResult.CreateFailure(errors, successCount, stopwatch.Elapsed);
+ }
+
+ return OperationResult.CreateSuccess(successCount, stopwatch.Elapsed);
+ }
+
+ private static IReadOnlyList InitializeActionSets(
+ IEnumerable actionSets,
+ IEnumerable providers,
+ ILogger logger)
+ {
+ var setMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (actionSets != null)
+ {
+ RegisterDirectActionSets(actionSets, setMap, logger);
+ }
+
+ if (providers != null)
+ {
+ RegisterProviderActionSets(providers, setMap, logger);
+ }
+
+ return setMap.Values.ToList();
+ }
+
+ private static void RegisterDirectActionSets(
+ IEnumerable actionSets,
+ Dictionary setMap,
+ ILogger logger)
+ {
+ foreach (var set in actionSets)
+ {
+ if (set == null)
+ {
+ continue;
+ }
+
+ if (!setMap.TryAdd(set.Id, set))
+ {
+ logger.LogWarning("Duplicate action set ID {Id} ignored from direct registration", set.Id);
+ }
+ }
+ }
+
+ private static void RegisterProviderActionSets(
+ IEnumerable providers,
+ Dictionary setMap,
+ ILogger logger)
+ {
+ foreach (var provider in providers)
+ {
+ try
+ {
+ foreach (var set in provider.GetActionSets())
+ {
+ if (set == null)
+ {
+ continue;
+ }
+
+ if (!setMap.TryAdd(set.Id, set))
+ {
+ logger.LogWarning("Duplicate action set ID {Id} ignored from provider {Provider}", set.Id, provider.GetType().Name);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load action sets from provider {Provider}", provider.GetType().Name);
+ }
+ }
+ }
+
+ private async Task ProcessActionSetAsync(
+ IActionSet actionSet,
+ GameInstallation installation,
+ int index,
+ int totalCount,
+ List errors,
+ CancellationToken ct)
+ {
+ var eligible = await CheckEligibilityAsync(actionSet, installation, errors, ct);
+ if (eligible != ExecutionOutcome.Success)
+ {
+ return eligible;
+ }
+
+ return await ApplySingleActionSetAsync(actionSet, installation, index, totalCount, errors, ct);
+ }
+
+ private async Task CheckEligibilityAsync(
+ IActionSet actionSet,
+ GameInstallation installation,
+ List errors,
+ CancellationToken ct)
+ {
+ try
+ {
+ if (!await actionSet.IsApplicableAsync(installation, ct))
+ {
+ logger.LogDebug("Skipping {Title} - not applicable", actionSet.Title);
+ return ExecutionOutcome.Skipped;
+ }
+
+ if (await actionSet.IsAppliedAsync(installation, ct))
+ {
+ logger.LogDebug("Skipping {Title} - already applied", actionSet.Title);
+ return ExecutionOutcome.Skipped;
+ }
+
+ return ExecutionOutcome.Success;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogError(ex, "Error checking eligibility for {Title}", actionSet.Title);
+ errors.Add($"Error checking {actionSet.Title}: {ex.Message}");
+ if (actionSet.IsCrucialFix)
+ {
+ logger.LogError("Critical fix {Title} eligibility check failed. Aborting sequence.", actionSet.Title);
+ errors.Add($"Critical fix '{actionSet.Title}' eligibility check failed. Remaining fixes were not applied.");
+ return ExecutionOutcome.FailedCritical;
+ }
+
+ return ExecutionOutcome.FailedNonCritical;
+ }
+ }
+
+ private async Task ApplySingleActionSetAsync(
+ IActionSet actionSet,
+ GameInstallation installation,
+ int index,
+ int totalCount,
+ List errors,
+ CancellationToken ct)
+ {
+ try
+ {
+ logger.LogInformation("Applying action set {Index}/{Total}: {Title}", index, totalCount, actionSet.Title);
+ var result = await actionSet.ApplyAsync(installation, ct);
+
+ if (result.Success)
+ {
+ logger.LogInformation("Successfully applied {Title}", actionSet.Title);
+ return ExecutionOutcome.Success;
+ }
+
+ var errorMessage = result.ErrorMessage ?? "Unknown error";
+ logger.LogWarning("Failed to apply {Title}: {Error}", actionSet.Title, errorMessage);
+ errors.Add($"{actionSet.Title}: {errorMessage}");
+
+ if (actionSet.IsCrucialFix)
+ {
+ logger.LogError("Critical fix {Title} failed. Aborting remaining action sets.", actionSet.Title);
+ errors.Add($"Critical fix '{actionSet.Title}' failed. Remaining fixes were not applied.");
+ return ExecutionOutcome.FailedCritical;
+ }
+
+ return ExecutionOutcome.FailedNonCritical;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error applying {Title}", actionSet.Title);
+ errors.Add($"{actionSet.Title}: {ex.Message}");
+
+ if (actionSet.IsCrucialFix)
+ {
+ logger.LogError(ex, "Critical fix {Title} threw unexpected exception. Aborting remaining action sets.", actionSet.Title);
+ errors.Add($"Critical fix '{actionSet.Title}' encountered an unexpected error. Remaining fixes were not applied.");
+ return ExecutionOutcome.FailedCritical;
+ }
+
+ return ExecutionOutcome.FailedNonCritical;
+ }
+ }
+}
diff --git a/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs
new file mode 100644
index 000000000..689351ccb
--- /dev/null
+++ b/GenHub/GenHub.Core/Features/ActionSets/BaseActionSet.cs
@@ -0,0 +1,270 @@
+namespace GenHub.Core.Features.ActionSets;
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+
+///
+/// Abstract base class for action sets, providing common functionality.
+///
+public abstract class BaseActionSet(ILogger logger) : IActionSet
+{
+ ///
+ public abstract string Id { get; }
+
+ ///
+ public abstract string Title { get; }
+
+ ///
+ public virtual string Description => Title;
+
+ ///
+ public virtual string DetailedDescription => string.Empty;
+
+ ///
+ public virtual string Category => IsCoreFix ? "Core & Stability" : "Compatibility";
+
+ ///
+ public abstract bool IsCoreFix { get; }
+
+ ///
+ public abstract bool IsCrucialFix { get; }
+
+ ///
+ /// Gets the logger instance.
+ ///
+ protected ILogger Logger => logger;
+
+ ///
+ ///
+ /// Default implementation returns true if either Generals or Zero Hour is detected in the installation.
+ /// Action sets that do not require a game installation should override this method.
+ ///
+ public virtual Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default)
+ => Task.FromResult(installation.HasGenerals || installation.HasZeroHour);
+
+ ///
+ public virtual Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ => Task.FromResult(false);
+
+ ///
+ public async Task ApplyAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ logger.LogInformation("Applying ActionSet {Title} ({Id}) to {InstallationPath}...", Title, Id, installation.InstallationPath);
+ try
+ {
+ var result = await ApplyInternalAsync(installation, ct);
+ if (result.Success)
+ {
+ logger.LogInformation("Successfully applied ActionSet {Title} ({Id})", Title, Id);
+ }
+ else
+ {
+ logger.LogWarning("Failed to apply ActionSet {Title} ({Id}): {Error}", Title, Id, result.ErrorMessage);
+ }
+
+ return result;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error applying ActionSet {Title} ({Id})", Title, Id);
+ return new ActionSetResult(false, ex.Message);
+ }
+ }
+
+ ///
+ public async Task UndoAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ logger.LogInformation("Undoing ActionSet {Title} ({Id}) from {InstallationPath}...", Title, Id, installation.InstallationPath);
+ try
+ {
+ var result = await UndoInternalAsync(installation, ct);
+ if (result.Success)
+ {
+ logger.LogInformation("Successfully undid ActionSet {Title} ({Id})", Title, Id);
+ }
+ else
+ {
+ logger.LogWarning("Failed to undo ActionSet {Title} ({Id}): {Error}", Title, Id, result.ErrorMessage);
+ }
+
+ return result;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error undoing ActionSet {Title} ({Id})", Title, Id);
+ return new ActionSetResult(false, ex.Message);
+ }
+ }
+
+ ///
+ /// Helper to return a successful result.
+ ///
+ /// A successful ActionSetResult.
+ protected static ActionSetResult Success() => new(true);
+
+ ///
+ /// Helper to return a failed result.
+ ///
+ /// The error message.
+ /// A failed ActionSetResult.
+ protected static ActionSetResult Failure(string message) => new(false, message);
+
+ ///
+ /// Checks if the marker file exists on disk.
+ ///
+ /// The marker file path.
+ /// true if the marker exists; otherwise, false.
+ protected static bool MarkerExists(string markerPath) => File.Exists(markerPath);
+
+ ///
+ /// Writes a marker file with the current UTC timestamp.
+ ///
+ /// The marker file path.
+ protected static void WriteMarkerFile(string markerPath)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(markerPath);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
+ }
+ catch (IOException)
+ {
+ // Ignored - marker write non-fatal
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored - marker write non-fatal
+ }
+ }
+
+ ///
+ /// Safely reads all lines from a marker file.
+ ///
+ /// The marker file path.
+ /// The array of lines if read successfully; an empty array if the file does not exist; or null if reading failed due to an I/O error.
+ protected static string[]? ReadMarkerLinesSafely(string markerPath)
+ {
+ try
+ {
+ return File.Exists(markerPath) ? File.ReadAllLines(markerPath) : [];
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Deletes a marker file if it exists on disk.
+ ///
+ /// The marker file path.
+ protected static void DeleteMarkerFile(string markerPath)
+ {
+ DeleteFileSafely(markerPath);
+ }
+
+ ///
+ /// Safely deletes a file if it exists, clearing read-only attributes.
+ ///
+ /// The file path to delete.
+ protected static void DeleteFileSafely(string? path)
+ {
+ if (string.IsNullOrEmpty(path) || !File.Exists(path))
+ {
+ return;
+ }
+
+ try
+ {
+ File.SetAttributes(path, FileAttributes.Normal);
+ File.Delete(path);
+ }
+ catch (IOException)
+ {
+ // Ignored - cleanup failure non-fatal
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored - cleanup failure non-fatal
+ }
+ }
+
+ ///
+ /// Safely deletes a directory and its contents if it exists, clearing read-only attributes.
+ ///
+ /// The directory path to delete.
+ protected static void DeleteDirectorySafely(string? path)
+ {
+ if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
+ {
+ return;
+ }
+
+ try
+ {
+ foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ {
+ try
+ {
+ File.SetAttributes(file, FileAttributes.Normal);
+ }
+ catch (IOException)
+ {
+ // Ignored - best-effort attribute reset before directory deletion
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored - best-effort attribute reset before directory deletion
+ }
+ }
+
+ Directory.Delete(path, true);
+ }
+ catch (IOException)
+ {
+ // Ignored - directory cleanup failure non-fatal
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored - directory cleanup failure non-fatal
+ }
+ }
+
+ ///
+ /// Implements the specific application logic.
+ ///
+ /// The game installation.
+ /// The cancellation token.
+ /// The result of the operation.
+ protected abstract Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct);
+
+ ///
+ /// Implements the specific undo logic.
+ ///
+ /// The game installation.
+ /// The cancellation token.
+ /// The result of the operation.
+ protected abstract Task UndoInternalAsync(GameInstallation installation, CancellationToken ct);
+}
diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs
new file mode 100644
index 000000000..9b839192e
--- /dev/null
+++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSet.cs
@@ -0,0 +1,149 @@
+namespace GenHub.Core.Features.ActionSets;
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.GameInstallations;
+
+///
+/// Defines a set of actions to fix or enhance a game installation.
+///
+public interface IActionSet
+{
+ ///
+ /// Gets the unique identifier for this action set.
+ ///
+ string Id { get; }
+
+ ///
+ /// Gets the title of the action set.
+ ///
+ string Title { get; }
+
+ ///
+ /// Gets the concise user-facing description of what the action set does.
+ ///
+ string Description { get; }
+
+ ///
+ /// Gets the detailed description explaining the technical mechanics, files modified, and problem solved.
+ ///
+ string DetailedDescription { get; }
+
+ ///
+ /// Gets the category of the action set.
+ ///
+ string Category { get; }
+
+ ///
+ /// Gets a value indicating whether this is a core fix applied by default.
+ ///
+ bool IsCoreFix { get; }
+
+ ///
+ /// Gets a value indicating whether this is a crucial fix for game stability.
+ ///
+ bool IsCrucialFix { get; }
+
+ ///
+ /// Checks if the action set is applicable to the current system and game installation.
+ ///
+ /// The game installation to check.
+ /// The cancellation token.
+ /// A task representing the asynchronous operation, returning true if applicable.
+ Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default);
+
+ ///
+ /// Checks if the action set has already been applied.
+ ///
+ /// The game installation to check.
+ /// The cancellation token.
+ /// A task representing the asynchronous operation, returning true if applied.
+ Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default);
+
+ ///
+ /// Applies the action set patches.
+ ///
+ /// The game installation to patch.
+ /// The cancellation token.
+ /// A task representing the asynchronous operation, returning the result of the action.
+ Task ApplyAsync(GameInstallation installation, CancellationToken ct = default);
+
+ ///
+ /// Undoes the action set patches if possible.
+ ///
+ /// The game installation to revert.
+ /// The cancellation token.
+ /// A task representing the asynchronous operation, returning the result of the undo operation.
+ Task UndoAsync(GameInstallation installation, CancellationToken ct = default);
+}
+
+///
+/// Represents the result of an action set operation.
+///
+public record ActionSetResult
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Whether the operation succeeded.
+ /// Error message if the operation failed.
+ /// Detailed list of actions taken during the operation.
+ public ActionSetResult(bool success, string? errorMessage = null, IReadOnlyList? details = null)
+ {
+ Success = success;
+ ErrorMessage = errorMessage;
+ Details = details ?? [];
+ }
+
+ ///
+ /// Gets a value indicating whether the operation succeeded.
+ ///
+ public bool Success { get; init; }
+
+ ///
+ /// Gets the error message if the operation failed.
+ ///
+ public string? ErrorMessage { get; init; }
+
+ ///
+ /// Gets the detailed list of actions taken during the operation.
+ ///
+ public IReadOnlyList Details { get; init; }
+
+ ///
+ /// Creates a new ActionSetResult with an additional detail message.
+ ///
+ /// The detail message to add.
+ /// A new ActionSetResult with the detail added.
+ public ActionSetResult WithDetail(string detail)
+ {
+ var newDetails = new List(Details) { detail };
+ return new ActionSetResult(Success, ErrorMessage, newDetails);
+ }
+
+ ///
+ /// Creates a successful result with the given details.
+ ///
+ /// The details of what was done.
+ /// A successful ActionSetResult.
+ public static ActionSetResult SuccessWithDetails(params string[] details) =>
+ new(true, null, [.. details]);
+
+ ///
+ /// Creates a failed result with the given error and optional details.
+ ///
+ /// The error message.
+ /// Optional details of what was attempted.
+ /// A failed ActionSetResult.
+ public static ActionSetResult FailureWithDetails(string error, params string[] details) =>
+ new(false, error, [.. details]);
+
+ ///
+ /// Formats the details as a multi-line string for display.
+ ///
+ /// A formatted string of all details.
+ public string FormatDetails() => Details.Count > 0
+ ? string.Join("\n", Details)
+ : "No details available.";
+}
diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs
new file mode 100644
index 000000000..71f47f3aa
--- /dev/null
+++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSetOrchestrator.cs
@@ -0,0 +1,36 @@
+namespace GenHub.Core.Features.ActionSets;
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Core.Models.Results;
+
+///
+/// Service responsible for managing and executing action sets.
+///
+public interface IActionSetOrchestrator
+{
+ ///
+ /// Gets all registered action sets.
+ ///
+ /// A list of action sets.
+ IReadOnlyList GetAllActionSets();
+
+ ///
+ /// Gets applicable core fixes for a given installation.
+ ///
+ /// The game installation.
+ /// Cancellation token.
+ /// A task returning the list of applicable core fixes.
+ Task> GetApplicableCoreFixesAsync(GameInstallation installation, CancellationToken ct = default);
+
+ ///
+ /// Applies a collection of action sets to an installation.
+ ///
+ /// The installation.
+ /// The action sets to apply.
+ /// Cancellation token.
+ /// Operation result containing details of success/failure.
+ Task> ApplyActionSetsAsync(GameInstallation installation, IEnumerable actionSets, CancellationToken ct = default);
+}
diff --git a/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs b/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs
new file mode 100644
index 000000000..302bd9b5c
--- /dev/null
+++ b/GenHub/GenHub.Core/Features/ActionSets/IActionSetProvider.cs
@@ -0,0 +1,15 @@
+namespace GenHub.Core.Features.ActionSets;
+
+using System.Collections.Generic;
+
+///
+/// Defines a provider for discovering ActionSets.
+///
+public interface IActionSetProvider
+{
+ ///
+ /// Gets the action sets provided by this source.
+ ///
+ /// A collection of action sets.
+ IEnumerable GetActionSets();
+}
diff --git a/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs b/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs
new file mode 100644
index 000000000..63c94bfad
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/DownloadSecurityValidator.cs
@@ -0,0 +1,435 @@
+namespace GenHub.Core.Helpers;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Results;
+
+///
+/// Provides security validation for downloaded executables and packages, including SHA-256 and Authenticode checks.
+///
+public static class DownloadSecurityValidator
+{
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WinTrustFileInfo
+ {
+ private readonly uint _cbStruct;
+ [MarshalAs(UnmanagedType.LPWStr)]
+ private readonly string _pszFilePath;
+ private readonly IntPtr _hFile;
+ private readonly IntPtr _pgKnownSubject;
+
+ public WinTrustFileInfo(string filePath)
+ {
+ _cbStruct = (uint)Marshal.SizeOf();
+ _pszFilePath = filePath;
+ _hFile = IntPtr.Zero;
+ _pgKnownSubject = IntPtr.Zero;
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WinTrustData
+ {
+ private readonly uint _cbStruct;
+ private readonly IntPtr _pPolicyCallbackData;
+ private readonly IntPtr _pSIPClientData;
+ private readonly uint _dwUIChoice;
+ private readonly uint _fdwRevocationChecks;
+ private readonly uint _dwUnionChoice;
+ private readonly IntPtr _pFile;
+ private readonly uint _dwStateAction;
+ private readonly IntPtr _hWVTStateData;
+ [MarshalAs(UnmanagedType.LPWStr)]
+ private readonly string? _pwszURLReference;
+ private readonly uint _dwProvFlags;
+ private readonly uint _dwUIContext;
+ private readonly IntPtr _pSignatureSettings;
+
+ public WinTrustData(IntPtr filePtr)
+ {
+ _cbStruct = (uint)Marshal.SizeOf();
+ _pPolicyCallbackData = IntPtr.Zero;
+ _pSIPClientData = IntPtr.Zero;
+ _dwUIChoice = 2; // WTD_UI_NONE
+ _fdwRevocationChecks = 1; // WTD_REVOKE_WHOLECHAIN
+ _dwUnionChoice = 1; // WTD_CHOICE_FILE
+ _pFile = filePtr;
+ _dwStateAction = 0; // WTD_STATEACTION_IGNORE
+ _hWVTStateData = IntPtr.Zero;
+ _pwszURLReference = null;
+ _dwProvFlags = 0x00000040; // WTD_CACHE_ONLY_URL_RETRIEVAL
+ _dwUIContext = 0;
+ _pSignatureSettings = IntPtr.Zero;
+ }
+ }
+
+ private const int CertEExpired = unchecked((int)0x800B0101);
+ private const int CertEValidityPeriodNesting = unchecked((int)0x800B0102);
+
+ private static readonly Guid WinTrustActionGenericVerifyV2 = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE");
+
+ ///
+ /// Computes the SHA-256 hash of a file as a lowercase hexadecimal string.
+ ///
+ /// Path to the file to hash.
+ /// The cancellation token.
+ /// Lowercase hex SHA-256 string.
+ public static async Task ComputeSha256Async(string filePath, CancellationToken ct = default)
+ {
+ await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
+ return await ComputeSha256Async(stream, ct);
+ }
+
+ ///
+ /// Computes the SHA-256 hash of a stream as a lowercase hexadecimal string.
+ ///
+ /// The stream to hash.
+ /// The cancellation token.
+ /// Lowercase hex SHA-256 string.
+ public static async Task ComputeSha256Async(Stream stream, CancellationToken ct = default)
+ {
+ using var sha256 = SHA256.Create();
+ var hashBytes = await sha256.ComputeHashAsync(stream, ct);
+ return Convert.ToHexString(hashBytes).ToLowerInvariant();
+ }
+
+ ///
+ /// Validates the Authenticode signature and publisher of a file.
+ /// On Windows, performs WinVerifyTrust trust and integrity verification.
+ ///
+ /// Path to the executable or library file.
+ /// Expected publisher subject or issuer substring (e.g. "Microsoft Corporation").
+ /// Whether to accept legacy expired certificates if publisher matches.
+ /// Operation result indicating success or failure.
+ public static OperationResult ValidateAuthenticodeSignature(
+ string filePath,
+ string? expectedPublisher = null,
+ bool allowExpiredCertificates = false)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ return OperationResult.CreateFailure("File to validate does not exist.");
+ }
+
+ // On non-Windows, Authenticode trust verification is not supported; fail closed
+ if (!OperatingSystem.IsWindows())
+ {
+ return OperationResult.CreateFailure("Authenticode signature validation is only supported on Windows.");
+ }
+
+ var trustResult = VerifyWindowsAuthenticodeTrust(filePath);
+ if (!trustResult.Success)
+ {
+ return OperationResult.CreateFailure(trustResult.Errors);
+ }
+
+ int hresult = trustResult.Data;
+ if (hresult != 0)
+ {
+ bool isExpiredCert = hresult == CertEExpired || hresult == CertEValidityPeriodNesting;
+ if (!isExpiredCert || !allowExpiredCertificates)
+ {
+ return OperationResult.CreateFailure(
+ $"Authenticode trust verification failed for '{Path.GetFileName(filePath)}' with error code 0x{hresult:X8}.");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(expectedPublisher))
+ {
+ return VerifyPublisherMatch(filePath, expectedPublisher);
+ }
+
+ return OperationResult.CreateSuccess(true);
+ }
+
+ ///
+ /// Validates a downloaded file against pinned SHA-256 hashes and/or Authenticode publisher signatures.
+ /// Fails closed if any specified check fails.
+ ///
+ /// Path to the file to validate.
+ /// Optional list of allowed SHA-256 hashes.
+ /// Optional expected Authenticode publisher substring.
+ /// Whether to accept legacy expired certificates if publisher matches.
+ /// The cancellation token.
+ /// Operation result indicating validation success or failure.
+ public static async Task> ValidateFileAsync(
+ string filePath,
+ IReadOnlyList? allowedSha256Hashes = null,
+ string? expectedAuthenticodePublisher = null,
+ bool allowExpiredCertificates = false,
+ CancellationToken ct = default)
+ {
+ if (!File.Exists(filePath))
+ {
+ return OperationResult.CreateFailure($"File '{filePath}' does not exist for validation.");
+ }
+
+ bool hasHashCheck = allowedSha256Hashes is { Count: > 0 };
+ bool hasPublisherCheck = !string.IsNullOrWhiteSpace(expectedAuthenticodePublisher);
+
+ if (!hasHashCheck && !hasPublisherCheck)
+ {
+ return OperationResult.CreateFailure("No validation criteria (hash or publisher) specified.");
+ }
+
+ // Check SHA-256 hash if specified
+ bool hashMatched = false;
+ if (allowedSha256Hashes is { Count: > 0 })
+ {
+ var actualHash = await ComputeSha256Async(filePath, ct);
+ hashMatched = allowedSha256Hashes.Any(h => string.Equals(h, actualHash, StringComparison.OrdinalIgnoreCase));
+ if (!hashMatched && !hasPublisherCheck)
+ {
+ return OperationResult.CreateFailure(
+ $"SHA-256 hash mismatch for '{Path.GetFileName(filePath)}'. Computed hash: '{actualHash}'. Expected one of: [{string.Join(", ", allowedSha256Hashes)}].");
+ }
+ }
+
+ // Check Authenticode publisher if specified
+ if (hasPublisherCheck)
+ {
+ var authResult = ValidateAuthenticodeSignature(filePath, expectedAuthenticodePublisher, allowExpiredCertificates);
+ if (!authResult.Success)
+ {
+ // If hash check was also specified and matched, allow fallback to known pinned hash
+ if (hasHashCheck && hashMatched)
+ {
+ return OperationResult.CreateSuccess(true);
+ }
+
+ return authResult;
+ }
+ }
+
+ return OperationResult.CreateSuccess(true);
+ }
+
+ ///
+ /// Validates a file using SHA-256 hash and/or Authenticode signature checks, and returns a shared-read, locked stream if valid.
+ /// The caller is responsible for disposing the returned stream to release the file lock.
+ ///
+ /// The absolute path to the file to validate and lock.
+ /// Optional collection of allowed SHA-256 hashes (hex string, case-insensitive).
+ /// Optional expected publisher common name (CN) in Authenticode certificate.
+ /// Whether to accept expired certificates if valid at signing time.
+ /// Cancellation token.
+ /// A successful OperationResult containing the locked FileStream, or a failure result with validation errors.
+ public static async Task> ValidateAndLockFileAsync(
+ string filePath,
+ IReadOnlyList? allowedSha256Hashes = null,
+ string? expectedAuthenticodePublisher = null,
+ bool allowExpiredCertificates = false,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(filePath))
+ {
+ return OperationResult.CreateFailure("File path cannot be null or empty.");
+ }
+
+ if (!File.Exists(filePath))
+ {
+ return OperationResult.CreateFailure($"File '{filePath}' does not exist.");
+ }
+
+ // Remove ReadOnly attribute if present so caller can overwrite/delete later if needed
+ try
+ {
+ var attributes = File.GetAttributes(filePath);
+ if ((attributes & FileAttributes.ReadOnly) != 0)
+ {
+ File.SetAttributes(filePath, attributes & ~FileAttributes.ReadOnly);
+ }
+ }
+ catch (IOException)
+ {
+ // Non-critical if filesystem does not support read-only attribute
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Non-critical if filesystem does not support read-only attribute
+ }
+ catch (ArgumentException)
+ {
+ // Non-critical if filesystem does not support read-only attribute
+ }
+ catch (NotSupportedException)
+ {
+ // Non-critical if filesystem does not support read-only attribute
+ }
+
+ FileStream? stream = null;
+ try
+ {
+ stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, 81920, true);
+
+ var verifyResult = await VerifyStreamHashAndSignatureAsync(
+ stream,
+ filePath,
+ allowedSha256Hashes,
+ expectedAuthenticodePublisher,
+ allowExpiredCertificates,
+ ct);
+
+ if (!verifyResult.Success)
+ {
+ await stream.DisposeAsync();
+ stream = null;
+ return OperationResult.CreateFailure(verifyResult.Errors);
+ }
+
+ return OperationResult.CreateSuccess(stream);
+ }
+ catch (Exception ex)
+ {
+ if (stream != null)
+ {
+ await stream.DisposeAsync();
+ }
+
+ return OperationResult.CreateFailure($"Failed to validate and lock file '{filePath}': {ex.Message}");
+ }
+ }
+
+ private static async Task> VerifyStreamHashAndSignatureAsync(
+ FileStream stream,
+ string filePath,
+ IReadOnlyList? allowedSha256Hashes,
+ string? expectedAuthenticodePublisher,
+ bool allowExpiredCertificates,
+ CancellationToken ct)
+ {
+ bool hasHashCheck = allowedSha256Hashes is { Count: > 0 };
+ bool hasPublisherCheck = !string.IsNullOrWhiteSpace(expectedAuthenticodePublisher);
+
+ if (!hasHashCheck && !hasPublisherCheck)
+ {
+ return OperationResult.CreateFailure("No validation criteria (hash or publisher) specified.");
+ }
+
+ bool hashMatched = false;
+ if (allowedSha256Hashes is { Count: > 0 })
+ {
+ var actualHash = await ComputeSha256Async(stream, ct);
+ stream.Position = 0;
+ hashMatched = allowedSha256Hashes.Any(h => string.Equals(h, actualHash, StringComparison.OrdinalIgnoreCase));
+ if (!hashMatched && !hasPublisherCheck)
+ {
+ return OperationResult.CreateFailure(
+ $"SHA-256 hash mismatch for '{Path.GetFileName(filePath)}'. Computed hash: '{actualHash}'. Expected one of: [{string.Join(", ", allowedSha256Hashes)}].");
+ }
+ }
+
+ if (hasPublisherCheck)
+ {
+ var authResult = ValidateAuthenticodeSignature(filePath, expectedAuthenticodePublisher, allowExpiredCertificates);
+ if (!authResult.Success)
+ {
+ if (hasHashCheck && hashMatched)
+ {
+ return OperationResult.CreateSuccess(true);
+ }
+
+ return authResult;
+ }
+ }
+
+ return OperationResult.CreateSuccess(true);
+ }
+
+ private static OperationResult VerifyPublisherMatch(string filePath, string expectedPublisher)
+ {
+ try
+ {
+ using var cert = new X509Certificate2(X509Certificate.CreateFromSignedFile(filePath));
+
+ var subject = cert.Subject;
+ var issuer = cert.Issuer;
+
+ if (!subject.Contains(expectedPublisher, StringComparison.OrdinalIgnoreCase) &&
+ !issuer.Contains(expectedPublisher, StringComparison.OrdinalIgnoreCase))
+ {
+ return OperationResult.CreateFailure(
+ $"Authenticode signature publisher mismatch. Expected publisher containing '{expectedPublisher}', but found subject '{subject}' and issuer '{issuer}'.");
+ }
+
+ return OperationResult.CreateSuccess(true);
+ }
+ catch (CryptographicException ex)
+ {
+ return OperationResult.CreateFailure($"Authenticode certificate verification failed: {ex.Message}");
+ }
+ catch (IOException ex)
+ {
+ return OperationResult.CreateFailure($"Authenticode certificate read failed: {ex.Message}");
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return OperationResult.CreateFailure($"Authenticode certificate access denied: {ex.Message}");
+ }
+ }
+
+ private static OperationResult VerifyWindowsAuthenticodeTrust(string filePath)
+ {
+ var fileInfo = new WinTrustFileInfo(Path.GetFullPath(filePath));
+
+ var pFileInfo = IntPtr.Zero;
+ var pData = IntPtr.Zero;
+ bool fileInfoMarshaled = false;
+ bool trustDataMarshaled = false;
+
+ try
+ {
+ pFileInfo = Marshal.AllocHGlobal(Marshal.SizeOf());
+ Marshal.StructureToPtr(fileInfo, pFileInfo, false);
+ fileInfoMarshaled = true;
+
+ var trustData = new WinTrustData(pFileInfo);
+
+ pData = Marshal.AllocHGlobal(Marshal.SizeOf());
+ Marshal.StructureToPtr(trustData, pData, false);
+ trustDataMarshaled = true;
+
+ int result = WinVerifyTrust(IntPtr.Zero, WinTrustActionGenericVerifyV2, pData);
+ return OperationResult.CreateSuccess(result);
+ }
+ catch (Exception ex)
+ {
+ return OperationResult.CreateFailure($"WinVerifyTrust exception: {ex.Message}");
+ }
+ finally
+ {
+ if (trustDataMarshaled)
+ {
+ Marshal.DestroyStructure(pData);
+ }
+
+ if (pData != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(pData);
+ }
+
+ if (fileInfoMarshaled)
+ {
+ Marshal.DestroyStructure(pFileInfo);
+ }
+
+ if (pFileInfo != IntPtr.Zero)
+ {
+ Marshal.FreeHGlobal(pFileInfo);
+ }
+ }
+ }
+
+ [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false, CharSet = CharSet.Unicode)]
+ private static extern int WinVerifyTrust(
+ IntPtr hwnd,
+ [MarshalAs(UnmanagedType.LPStruct)] Guid pgActionID,
+ IntPtr pWVTData);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs b/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs
index f9dce3aba..a7d7aa327 100644
--- a/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Shortcuts/IShortcutService.cs
@@ -37,4 +37,22 @@ public interface IShortcutService
/// Optional custom name for the shortcut. If null, uses the profile name.
/// The full path to the shortcut file.
string GetShortcutPath(GameProfile profile, string? shortcutName = null);
+
+ ///
+ /// Creates a shortcut at the specified path.
+ ///
+ /// The path where the shortcut will be created.
+ /// The path to the target executable.
+ /// Optional command line arguments.
+ /// Optional working directory.
+ /// Optional description.
+ /// Optional icon path.
+ /// An operation result indicating success or failure.
+ Task> CreateShortcutAsync(
+ string shortcutPath,
+ string targetPath,
+ string? arguments = null,
+ string? workingDirectory = null,
+ string? description = null,
+ string? iconPath = null);
}
diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs
index 5bf1804e7..d54c56c99 100644
--- a/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs
+++ b/GenHub/GenHub.Core/Interfaces/Tools/IToolRegistry.cs
@@ -22,8 +22,8 @@ public interface IToolRegistry
/// Registers a new tool plugin with an assembly path (external tool).
///
/// The tool plugin to register.
- /// The path to the tool assembly.
- void RegisterTool(IToolPlugin plugin, string assemblyPath);
+ /// The path to the tool assembly. Null for built-in tools.
+ void RegisterTool(IToolPlugin plugin, string? assemblyPath);
///
/// Registers a new built-in tool plugin.
diff --git a/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs b/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs
new file mode 100644
index 000000000..29f3f9fe9
--- /dev/null
+++ b/GenHub/GenHub.Core/Messages/ToolStatusMessage.cs
@@ -0,0 +1,26 @@
+namespace GenHub.Core.Messages;
+
+///
+/// Defines the type of tool status message.
+///
+public enum MessageType
+{
+ /// Informational message.
+ Info,
+
+ /// Success message.
+ Success,
+
+ /// Error message.
+ Error,
+
+ /// Warning message.
+ Warning,
+}
+
+///
+/// Message sent when a tool's status changes.
+///
+/// The status message.
+/// The type of message.
+public record ToolStatusMessage(string Message, MessageType Type = MessageType.Info);
diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs
index 3e0eca4f6..743b66944 100644
--- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs
+++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherCatalog.cs
@@ -15,5 +15,5 @@ public class GenPatcherCatalog
///
/// Gets or sets the list of content items.
///
- public List Items { get; set; } = new();
+ public List Items { get; set; } = [];
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
index ad0432121..7d0fd467d 100644
--- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
+++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs
@@ -303,6 +303,17 @@ public static class GenPatcherContentRegistry
InstallTarget = ContentInstallTarget.Workspace,
},
+ ["genl"] = new GenPatcherContentMetadata
+ {
+ ContentCode = "genl",
+ DisplayName = "GenLauncher",
+ Description = "GenLauncher standalone launcher utility",
+ ContentType = ContentType.Addon,
+ TargetGame = GameType.ZeroHour,
+ Category = GenPatcherContentCategory.Tools,
+ InstallTarget = ContentInstallTarget.Workspace,
+ },
+
// Maps
["maod"] = new GenPatcherContentMetadata
{
diff --git a/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs b/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs
index 3437c0f91..43c917c1a 100644
--- a/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs
+++ b/GenHub/GenHub.Core/Models/Notifications/NotificationAction.cs
@@ -5,46 +5,31 @@ namespace GenHub.Core.Models.Notifications;
///
/// Represents a single action button on a notification.
///
-public class NotificationAction
+public class NotificationAction(
+ string text,
+ Action callback,
+ NotificationActionStyle style = NotificationActionStyle.Primary,
+ bool dismissOnExecute = true)
{
///
/// Gets the text to display on the action button.
///
- public string Text { get; init; }
+ public string Text { get; init; } = text ?? throw new ArgumentNullException(nameof(text));
///
/// Gets the callback to execute when the action button is clicked.
///
- public Action? Callback { get; private set; }
+ public Action? Callback { get; private set; } = callback ?? throw new ArgumentNullException(nameof(callback));
///
/// Gets the style of the action button.
///
- public NotificationActionStyle Style { get; init; }
+ public NotificationActionStyle Style { get; init; } = style;
///
/// Gets a value indicating whether the notification should be dismissed after executing this action.
///
- public bool DismissOnExecute { get; init; }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The text to display on the action button.
- /// The callback to execute when the action button is clicked.
- /// The style of the action button.
- /// Whether the notification should be dismissed after executing this action.
- public NotificationAction(
- string text,
- Action callback,
- NotificationActionStyle style = NotificationActionStyle.Primary,
- bool dismissOnExecute = true)
- {
- Text = text ?? throw new ArgumentNullException(nameof(text));
- Callback = callback ?? throw new ArgumentNullException(nameof(callback));
- Style = style;
- DismissOnExecute = dismissOnExecute;
- }
+ public bool DismissOnExecute { get; init; } = dismissOnExecute;
///
/// Clears the callback to prevent memory leaks.
@@ -53,4 +38,4 @@ public void ClearCallback()
{
Callback = null;
}
-}
\ No newline at end of file
+}
diff --git a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs
index 2c60d51bc..1759d3a50 100644
--- a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs
+++ b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using GenHub.Core.Models.Enums;
namespace GenHub.Core.Models.Notifications;
@@ -97,7 +99,7 @@ public NotificationMessage(
NotificationType type,
string title,
string message,
- int? autoDismissMilliseconds = 5000,
+ int? autoDismissMilliseconds = GenHub.Core.Constants.NotificationDurations.Medium,
string? actionText = null,
Action? action = null,
IReadOnlyList? actions = null,
diff --git a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs
index 180c828bc..25794a1c6 100644
--- a/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs
+++ b/GenHub/GenHub.Core/Services/Tools/ToolRegistry.cs
@@ -32,17 +32,17 @@ public IReadOnlyList GetAllTools()
}
///
- public void RegisterTool(IToolPlugin plugin, string assemblyPath)
+ public void RegisterTool(IToolPlugin plugin, string? assemblyPath)
{
_tools[plugin.Metadata.Id] = plugin;
- _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath;
+ if (assemblyPath != null)
+ {
+ _toolAssemblyPaths[plugin.Metadata.Id] = assemblyPath;
+ }
}
///
- public void RegisterTool(IToolPlugin plugin)
- {
- _tools[plugin.Metadata.Id] = plugin;
- }
+ public void RegisterTool(IToolPlugin plugin) => RegisterTool(plugin, null);
///
public bool UnregisterTool(string toolId)
diff --git a/GenHub/GenHub.Core/Services/Tools/ToolService.cs b/GenHub/GenHub.Core/Services/Tools/ToolService.cs
index 7b4dbfa0c..5711cc43c 100644
--- a/GenHub/GenHub.Core/Services/Tools/ToolService.cs
+++ b/GenHub/GenHub.Core/Services/Tools/ToolService.cs
@@ -14,13 +14,13 @@ namespace GenHub.Core.Services.Tools;
/// Plugin loader for loading tool plugins.
/// Registry for managing tool plugins.
/// Service for managing user settings.
-/// Collection of built-in tool plugins.
+/// Collection of built-in tool plugins from DI.
/// Logger for logging tool service activities.
public class ToolService(
IToolPluginLoader pluginLoader,
IToolRegistry toolRegistry,
IUserSettingsService userSettingsService,
- IEnumerable builtInPlugins,
+ IEnumerable builtInTools,
ILogger logger)
: IToolManager
{
@@ -34,20 +34,20 @@ public async Task> AddToolAsync(string assemblyPath
if (!pluginLoader.ValidatePlugin(assemblyPath))
{
logger.LogWarning("Tool plugin validation failed for: {AssemblyPath}", assemblyPath);
- return await Task.FromResult(OperationResult.CreateFailure("Invalid tool plugin assembly."));
+ return OperationResult.CreateFailure("Invalid tool plugin assembly.");
}
var plugin = pluginLoader.LoadPluginFromAssembly(assemblyPath);
if (plugin == null)
{
logger.LogWarning("Failed to load tool plugin from assembly: {AssemblyPath}", assemblyPath);
- return await Task.FromResult(OperationResult.CreateFailure("Failed to load tool plugin from assembly."));
+ return OperationResult.CreateFailure("Failed to load tool plugin from assembly.");
}
if (toolRegistry.GetToolById(plugin.Metadata.Id) != null)
{
logger.LogWarning("Tool with ID {ToolId} is already registered", plugin.Metadata.Id);
- return await Task.FromResult(OperationResult.CreateFailure("A tool with the same ID is already registered."));
+ return OperationResult.CreateFailure("A tool with the same ID is already registered.");
}
toolRegistry.RegisterTool(plugin, assemblyPath);
@@ -76,7 +76,7 @@ public async Task> AddToolAsync(string assemblyPath
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while adding tool plugin from assembly: {AssemblyPath}", assemblyPath);
- return await Task.FromResult(OperationResult.CreateFailure("An error occurred while adding the tool plugin."));
+ return OperationResult.CreateFailure("An error occurred while adding the tool plugin.");
}
}
@@ -87,31 +87,31 @@ public IReadOnlyList GetAllTools()
}
///
- public async Task>> LoadSavedToolsAsync()
+ public Task>> LoadSavedToolsAsync()
{
try
{
var loadedPlugins = new List();
- // First, register all built-in plugins from DI
- foreach (var builtInPlugin in builtInPlugins)
+ // 1. Register all built-in plugins from DI
+ foreach (var builtIn in builtInTools)
{
- var existingTool = toolRegistry.GetToolById(builtInPlugin.Metadata.Id);
+ var existingTool = toolRegistry.GetToolById(builtIn.Metadata.Id);
if (existingTool == null)
{
- builtInPlugin.Metadata.IsBundled = true;
- toolRegistry.RegisterTool(builtInPlugin);
- loadedPlugins.Add(builtInPlugin);
- logger.LogDebug("Registered built-in tool plugin: {PluginName}", builtInPlugin.Metadata.Name);
+ builtIn.Metadata.IsBundled = true;
+ toolRegistry.RegisterTool(builtIn);
+ loadedPlugins.Add(builtIn);
+ logger.LogDebug("Registered built-in tool plugin: {PluginName}", builtIn.Metadata.Name);
}
else
{
loadedPlugins.Add(existingTool);
- logger.LogDebug("Built-in tool plugin {PluginName} already registered", builtInPlugin.Metadata.Name);
+ logger.LogDebug("Built-in tool plugin {PluginName} already registered", builtIn.Metadata.Name);
}
}
- // Then, load external plugins from saved paths
+ // 2. Load external plugins from saved paths
var settings = userSettingsService.Get();
var toolPaths = settings.InstalledToolAssemblyPaths ?? [];
@@ -126,14 +126,18 @@ public async Task>> LoadSavedToolsAsync()
{
logger.LogDebug("Processing tool path: {Path}", path);
- // Check if tool is already loaded in registry
+ // Check if tool is already loaded in registry by its path
var existingTools = toolRegistry.GetAllTools();
var existingTool = existingTools.FirstOrDefault(t => toolRegistry.GetToolAssemblyPath(t.Metadata.Id) == path);
if (existingTool != null)
{
// Tool already loaded, reuse it
- loadedPlugins.Add(existingTool);
+ if (!loadedPlugins.Contains(existingTool))
+ {
+ loadedPlugins.Add(existingTool);
+ }
+
logger.LogDebug("Tool plugin from {Path} already loaded, reusing existing instance.", path);
continue;
}
@@ -155,14 +159,15 @@ public async Task>> LoadSavedToolsAsync()
logger.LogInformation(
"Loaded {Count} tool plugins ({BuiltIn} built-in, {External} external).",
loadedPlugins.Count,
- builtInPlugins.Count(),
+ builtInTools.Count(),
toolPaths.Count);
- return await Task.FromResult(OperationResult>.CreateSuccess(loadedPlugins));
+
+ return Task.FromResult(OperationResult>.CreateSuccess(loadedPlugins));
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred while loading saved tool plugins.");
- return await Task.FromResult(OperationResult>.CreateFailure("An error occurred while loading saved tool plugins."));
+ return Task.FromResult(OperationResult>.CreateFailure("An error occurred while loading saved tool plugins."));
}
}
@@ -174,24 +179,24 @@ public async Task> RemoveToolAsync(string toolId)
var tool = toolRegistry.GetToolById(toolId);
if (tool == null)
{
- return await Task.FromResult(OperationResult.CreateFailure("Tool not found."));
+ return OperationResult.CreateFailure("Tool not found.");
}
if (tool.Metadata.IsBundled)
{
logger.LogWarning("Attempted to remove bundled tool: {ToolName} ({ToolId})", tool.Metadata.Name, toolId);
- return await Task.FromResult(OperationResult.CreateFailure("Bundled tools cannot be removed."));
+ return OperationResult.CreateFailure("Bundled tools cannot be removed.");
}
var assemblyPath = toolRegistry.GetToolAssemblyPath(toolId);
if (assemblyPath == null)
{
- return await Task.FromResult(OperationResult.CreateFailure("Tool registration is incomplete (missing assembly path)."));
+ return OperationResult.CreateFailure("Tool registration is incomplete (missing assembly path).");
}
if (!toolRegistry.UnregisterTool(toolId))
{
- return await Task.FromResult(OperationResult.CreateFailure("Failed to unregister tool."));
+ return OperationResult.CreateFailure("Failed to unregister tool.");
}
userSettingsService.Update(settings =>
@@ -204,10 +209,10 @@ public async Task> RemoveToolAsync(string toolId)
logger.LogInformation("Tool with ID {ToolId} removed successfully.", toolId);
return OperationResult.CreateSuccess(true);
}
- catch
+ catch (Exception ex)
{
- logger.LogError("An error occurred while removing tool with ID: {ToolId}", toolId);
+ logger.LogError(ex, "An error occurred while removing tool with ID: {ToolId}", toolId);
return OperationResult.CreateFailure("An error occurred while removing the tool.");
}
}
-}
+}
\ No newline at end of file
diff --git a/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs b/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs
index 5c357c93b..d865f0f3e 100644
--- a/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs
+++ b/GenHub/GenHub.Linux/Features/Shortcuts/LinuxShortcutService.cs
@@ -120,6 +120,51 @@ public Task ShortcutExistsAsync(GameProfile profile)
return Task.FromResult(File.Exists(shortcutPath));
}
+ ///
+ public Task> CreateShortcutAsync(
+ string shortcutPath,
+ string targetPath,
+ string? arguments = null,
+ string? workingDirectory = null,
+ string? description = null,
+ string? iconPath = null)
+ {
+ try
+ {
+ var directory = Path.GetDirectoryName(shortcutPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ var name = Path.GetFileNameWithoutExtension(shortcutPath);
+ var comment = description ?? string.Empty;
+
+ var desktopEntry = BuildDesktopEntry(
+ name,
+ comment,
+ targetPath,
+ arguments ?? string.Empty,
+ workingDirectory ?? string.Empty,
+ iconPath ?? string.Empty);
+
+ File.WriteAllText(shortcutPath, desktopEntry, Encoding.UTF8);
+ MakeExecutable(shortcutPath);
+
+ logger.LogInformation(
+ "Created shortcut at {ShortcutPath} targeting {TargetPath}",
+ shortcutPath,
+ targetPath);
+
+ return Task.FromResult(OperationResult.CreateSuccess(true));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create shortcut at {ShortcutPath}", shortcutPath);
+ return Task.FromResult(OperationResult.CreateFailure($"Failed to create shortcut: {ex.Message}"));
+ }
+ }
+
///
public string GetShortcutPath(GameProfile profile, string? shortcutName = null)
{
diff --git a/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs b/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs
index 36916aaf9..e1543cc19 100644
--- a/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs
+++ b/GenHub/GenHub.MacOS/Features/Shortcuts/MacOSShortcutService.cs
@@ -81,6 +81,24 @@ public string GetShortcutPath(GameProfile profile, string? shortcutName = null)
return Path.Combine(desktopPath, $"{AppConstants.AppName}-{name}{ShortcutExtension}");
}
+ ///
+ public Task> CreateShortcutAsync(
+ string shortcutPath,
+ string targetPath,
+ string? arguments = null,
+ string? workingDirectory = null,
+ string? description = null,
+ string? iconPath = null)
+ {
+ logger.LogWarning(
+ "Shortcut creation is not implemented on macOS yet for target {TargetPath}",
+ targetPath);
+
+ return Task.FromResult(
+ OperationResult.CreateFailure(
+ "Shortcut creation is not implemented on macOS yet."));
+ }
+
private static string SanitizeFileName(string fileName)
{
var sanitized = new StringBuilder(fileName);
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs
new file mode 100644
index 000000000..acde75ecb
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/ActionSets/ActionSetOrchestratorTests.cs
@@ -0,0 +1,138 @@
+namespace GenHub.Tests.Core.Features.ActionSets;
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class ActionSetOrchestratorTests
+{
+ private readonly Mock> _loggerMock = new();
+
+ ///
+ /// Verifies that when a fix fails in a batch, partial success count is returned in OperationResult.Data.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ApplyActionSetsAsync_WhenFixFails_ReturnsPartialSuccessCountAsync()
+ {
+ var fix1 = new Mock();
+ fix1.SetupGet(f => f.Id).Returns("Fix1");
+ fix1.SetupGet(f => f.Title).Returns("Fix 1");
+ fix1.SetupGet(f => f.IsCrucialFix).Returns(false);
+ fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true));
+
+ var fix2 = new Mock();
+ fix2.SetupGet(f => f.Id).Returns("Fix2");
+ fix2.SetupGet(f => f.Title).Returns("Fix 2");
+ fix2.SetupGet(f => f.IsCrucialFix).Returns(false);
+ fix2.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ fix2.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ fix2.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(false, "Fix2 failed"));
+
+ var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object], [], _loggerMock.Object);
+ var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam);
+
+ var result = await orchestrator.ApplyActionSetsAsync(installation, [fix1.Object, fix2.Object]);
+
+ Assert.False(result.Success);
+ Assert.Equal(1, result.Data);
+ Assert.NotEmpty(result.Errors);
+ }
+
+ ///
+ /// Verifies that when a crucial fix fails, sequence aborts and partial success count is returned.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ApplyActionSetsAsync_WhenCrucialFixFails_AbortsAndReturnsPartialSuccessCountAsync()
+ {
+ var fix1 = new Mock();
+ fix1.SetupGet(f => f.Id).Returns("Fix1");
+ fix1.SetupGet(f => f.Title).Returns("Fix 1");
+ fix1.SetupGet(f => f.IsCrucialFix).Returns(false);
+ fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true));
+
+ var fix2 = new Mock();
+ fix2.SetupGet(f => f.Id).Returns("CrucialFix2");
+ fix2.SetupGet(f => f.Title).Returns("Crucial Fix 2");
+ fix2.SetupGet(f => f.IsCrucialFix).Returns(true);
+ fix2.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ fix2.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ fix2.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(false, "Crucial failure"));
+
+ var fix3 = new Mock(MockBehavior.Strict);
+ fix3.SetupGet(f => f.Id).Returns("Fix3");
+ fix3.SetupGet(f => f.Title).Returns("Fix 3");
+
+ var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object, fix3.Object], [], _loggerMock.Object);
+ var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam);
+
+ var result = await orchestrator.ApplyActionSetsAsync(installation, [fix1.Object, fix2.Object, fix3.Object]);
+
+ Assert.False(result.Success);
+ Assert.Equal(1, result.Data);
+ Assert.Contains(result.Errors, e => e.Contains("Crucial Fix 2") && e.Contains("Remaining fixes were not applied"));
+ fix3.Verify(f => f.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies that cancellation propagates OperationCanceledException.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ApplyActionSetsAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync()
+ {
+ var fix1 = new Mock();
+ fix1.SetupGet(f => f.Id).Returns("Fix1");
+ fix1.SetupGet(f => f.Title).Returns("Fix 1");
+ fix1.SetupGet(f => f.IsCrucialFix).Returns(false);
+ fix1.Setup(f => f.IsApplicableAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ fix1.Setup(f => f.IsAppliedAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false);
+ fix1.Setup(f => f.ApplyAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActionSetResult(true));
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var orchestrator = new ActionSetOrchestrator([fix1.Object], [], _loggerMock.Object);
+ var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam);
+
+ await Assert.ThrowsAsync(() =>
+ orchestrator.ApplyActionSetsAsync(installation, [fix1.Object], cts.Token));
+ }
+
+ ///
+ /// Verifies that duplicate action set IDs are deduplicated during initialization.
+ ///
+ [Fact]
+ public void InitializeActionSets_WithDuplicateIds_DeduplicatesSets()
+ {
+ var fix1 = new Mock();
+ fix1.SetupGet(f => f.Id).Returns("DuplicateFix");
+ fix1.SetupGet(f => f.Title).Returns("First Duplicate Fix");
+
+ var fix2 = new Mock();
+ fix2.SetupGet(f => f.Id).Returns("DuplicateFix");
+ fix2.SetupGet(f => f.Title).Returns("Second Duplicate Fix");
+
+ var orchestrator = new ActionSetOrchestrator([fix1.Object, fix2.Object], [], _loggerMock.Object);
+ var allSets = orchestrator.GetAllActionSets();
+
+ Assert.Single(allSets);
+ Assert.Equal("DuplicateFix", allSets[0].Id);
+ Assert.Equal("First Duplicate Fix", allSets[0].Title);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs
index ba6f293c3..c9b670279 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs
@@ -54,10 +54,6 @@ public GameInstallationServiceTests()
return Task.FromResult(clientResult);
});
- // Note: The service uses List, so the mock matches that concrete type.
- _clientOrchestratorMock.Setup(x => x.DetectGameClientsFromInstallationsAsync(It.IsAny>(), It.IsAny()))
- .ReturnsAsync(clientResult);
-
_service = new GameInstallationService(
_orchestratorMock.Object,
_clientOrchestratorMock.Object,
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
index 443b0f5cf..b7e44502c 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
@@ -538,7 +538,9 @@ public static LauncherHarness Create(
// Batch has no $$. PowerShell's own parent is the batch host, so it can report the
// PID the harness needs. If PowerShell is unavailable the loop simply writes
// nothing and Dispose falls back to leaving the launcher alone.
- var recordPid = $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
+ var recordPid = exitImmediately
+ ? string.Empty
+ : $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
// Leave the working directory afterwards: a batch host holds its current directory
// open, which would defeat the cleanup delete for the launcher's whole lifetime.
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs
index 6573acc2d..2339c3c33 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Notifications/NotificationFeedViewModelTests.cs
@@ -174,7 +174,7 @@ public void DismissNotificationCommand_ShouldDismissNotification()
}
///
- /// Verifies that cleans up subscriptions.
+ /// Verifies that cleans up subscriptions.
///
[Fact]
public void Dispose_CleansUpSubscriptions()
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs
new file mode 100644
index 000000000..5be6e07d4
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/DownloadSecurityValidatorTests.cs
@@ -0,0 +1,183 @@
+namespace GenHub.Tests.Core.Helpers;
+
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading.Tasks;
+using GenHub.Core.Helpers;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class DownloadSecurityValidatorTests
+{
+ ///
+ /// Verifies that ValidateFileAsync succeeds when SHA-256 matches allowed hashes.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ValidateFileAsync_WhenSha256Matches_ReturnsSuccessAsync()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var content = Encoding.UTF8.GetBytes("Test Content for Sha256");
+ await File.WriteAllBytesAsync(tempFile, content);
+
+ using var sha256 = SHA256.Create();
+ var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant();
+
+ var result = await DownloadSecurityValidator.ValidateFileAsync(
+ tempFile,
+ allowedSha256Hashes: [expectedHash]);
+
+ Assert.True(result.Success);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that ValidateFileAsync fails when SHA-256 does not match allowed hashes.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ValidateFileAsync_WhenSha256Mismatches_ReturnsFailureAsync()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var content = Encoding.UTF8.GetBytes("Test Content for Sha256 Mismatch");
+ await File.WriteAllBytesAsync(tempFile, content);
+
+ var wrongHash = "0000000000000000000000000000000000000000000000000000000000000000";
+
+ var result = await DownloadSecurityValidator.ValidateFileAsync(
+ tempFile,
+ allowedSha256Hashes: [wrongHash]);
+
+ Assert.False(result.Success);
+ Assert.Contains(result.Errors, e => e.Contains("SHA-256 hash mismatch"));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that ValidateAndLockFileAsync succeeds, sets read-only, and locks the file when SHA-256 matches.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ValidateAndLockFileAsync_WhenSha256Matches_ReturnsLockedStreamAsync()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var content = Encoding.UTF8.GetBytes("Test Content for Lock Validation");
+ await File.WriteAllBytesAsync(tempFile, content);
+
+ using var sha256 = SHA256.Create();
+ var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant();
+
+ var result = await DownloadSecurityValidator.ValidateAndLockFileAsync(
+ tempFile,
+ allowedSha256Hashes: [expectedHash]);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+
+ await using var stream = result.Data;
+ Assert.True(stream.CanRead);
+ Assert.False(stream.CanWrite);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ try
+ {
+ File.SetAttributes(tempFile, FileAttributes.Normal);
+ File.Delete(tempFile);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+ }
+
+ ///
+ /// Verifies that ValidateAndLockFileAsync returns failure when hash mismatches.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ValidateAndLockFileAsync_WhenSha256Mismatches_ReturnsFailureAsync()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var content = Encoding.UTF8.GetBytes("Mismatch Content for Lock Validation");
+ await File.WriteAllBytesAsync(tempFile, content);
+
+ var wrongHash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
+
+ var result = await DownloadSecurityValidator.ValidateAndLockFileAsync(
+ tempFile,
+ allowedSha256Hashes: [wrongHash]);
+
+ Assert.False(result.Success);
+ Assert.Null(result.Data);
+ Assert.NotEmpty(result.Errors);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ try
+ {
+ File.SetAttributes(tempFile, FileAttributes.Normal);
+ File.Delete(tempFile);
+ }
+ catch (IOException)
+ {
+ }
+ catch (UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+ }
+
+ ///
+ /// Verifies that ComputeSha256Async from stream returns the correct hexadecimal hash.
+ ///
+ /// A representing the test.
+ [Fact]
+ public async Task ComputeSha256Async_FromStream_ReturnsExpectedHashAsync()
+ {
+ var content = Encoding.UTF8.GetBytes("Stream Hash Content");
+ using var ms = new MemoryStream(content);
+
+ using var sha256 = SHA256.Create();
+ var expectedHash = Convert.ToHexString(sha256.ComputeHash(content)).ToLowerInvariant();
+
+ var computedHash = await DownloadSecurityValidator.ComputeSha256Async(ms);
+
+ Assert.Equal(expectedHash, computedHash);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs
new file mode 100644
index 000000000..48fd8cab9
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BaseActionSetTests.cs
@@ -0,0 +1,84 @@
+namespace GenHub.Tests.Windows.Features.ActionSets;
+
+using System.Threading.Tasks;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Tests for the class.
+///
+public class BaseActionSetTests
+{
+ private readonly Mock _loggerMock;
+ private readonly TestActionSet _testActionSet;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public BaseActionSetTests()
+ {
+ _loggerMock = new Mock();
+ _testActionSet = new TestActionSet(_loggerMock.Object);
+ }
+
+ ///
+ /// Verifies that ApplyAsync logs the action and calls the internal apply method.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task ApplyAsync_LogsAndCallsInternalAsync()
+ {
+ var installation = new GameInstallation("C:\\Test", GenHub.Core.Models.Enums.GameInstallationType.Unknown);
+
+ var result = await _testActionSet.ApplyAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.True(_testActionSet.ApplyCalled);
+
+ // Verify logging happened (simplistic check)
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString() != null && v.ToString()!.Contains("Applying ActionSet")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.AtLeastOnce);
+ }
+
+ private class TestActionSet : BaseActionSet
+ {
+ public bool ApplyCalled { get; private set; }
+
+ public TestActionSet(ILogger logger)
+ : base(logger)
+ {
+ }
+
+ public override string Id => "Test";
+
+ public override string Title => "Test Action Set";
+
+ public override bool IsCoreFix => false;
+
+ public override bool IsCrucialFix => false;
+
+ public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(true);
+
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(false);
+
+ protected override Task ApplyInternalAsync(GameInstallation installation, System.Threading.CancellationToken ct)
+ {
+ ApplyCalled = true;
+ return Task.FromResult(Success());
+ }
+
+ protected override Task UndoInternalAsync(GameInstallation installation, System.Threading.CancellationToken ct)
+ {
+ return Task.FromResult(Success());
+ }
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs
new file mode 100644
index 000000000..94ec32f2c
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/BasePackageDeploymentFixTests.cs
@@ -0,0 +1,493 @@
+namespace GenHub.Tests.Windows.Features.ActionSets;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using GenHub.Core.Exceptions;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using SharpCompress.Archives;
+using Xunit;
+
+///
+/// Unit tests for transactional safety, rollback retention, and undo behavior in .
+///
+public sealed class BasePackageDeploymentFixTests : IDisposable
+{
+ private readonly string _testDirectory;
+ private readonly Mock _loggerMock;
+ private readonly Mock _httpClientFactoryMock;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public BasePackageDeploymentFixTests()
+ {
+ _testDirectory = Path.Combine(Path.GetTempPath(), $"GenHub_PkgDeployTests_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDirectory);
+ _loggerMock = new Mock();
+ _httpClientFactoryMock = new Mock();
+ }
+
+ ///
+ /// Disposes the temporary test directory.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_testDirectory))
+ {
+ Directory.Delete(_testDirectory, true);
+ }
+ }
+ catch (IOException)
+ {
+ // Ignored on test cleanup
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Ignored on test cleanup
+ }
+ }
+
+ ///
+ /// Verifies that when undo encounters a missing recorded backup file,
+ /// it does not delete the destination file (to prevent data loss) and returns failure.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task Undo_WhenRecordedBackupIsMissing_RetainsDestinationFileAndFailsSafely()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstall");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var destFile = Path.Combine(installationPath, "game_asset.dll");
+ await File.WriteAllTextAsync(destFile, "ImportantOriginalOrModifiedContent");
+
+ var backupDir = fix.PublicGetBackupDirectory(installation);
+ var missingBackupFile = Path.Combine(backupDir, "missing_backup.bak");
+ var markerPath = fix.PublicGetMarkerPath(installation);
+ var markerDir = Path.GetDirectoryName(markerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{missingBackupFile}"]);
+
+ try
+ {
+ var result = await fix.UndoAsync(installation);
+
+ result.Success.Should().BeFalse();
+ File.Exists(destFile).Should().BeTrue("Destination file must be preserved when backup is missing");
+ var content = await File.ReadAllTextAsync(destFile);
+ content.Should().Be("ImportantOriginalOrModifiedContent");
+ }
+ finally
+ {
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that when a marker contains a destination path outside the game installation directory,
+ /// undo rejects modifying or deleting that arbitrary path and returns failure.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task Undo_WhenDestPathIsOutsideInstallationDirectory_RejectsPathAndFailsSafely()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstallDestOutside");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var outsideDir = Path.Combine(_testDirectory, "OutsideFolder");
+ Directory.CreateDirectory(outsideDir);
+ var outsideFile = Path.Combine(outsideDir, "critical_file.txt");
+ await File.WriteAllTextAsync(outsideFile, "CriticalProtectedContent");
+
+ var markerPath = fix.PublicGetMarkerPath(installation);
+ var markerDir = Path.GetDirectoryName(markerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ await File.WriteAllLinesAsync(markerPath, [$"{outsideFile}|"]);
+
+ try
+ {
+ var result = await fix.UndoAsync(installation);
+
+ result.Success.Should().BeFalse();
+ File.Exists(outsideFile).Should().BeTrue("Arbitrary files outside installation directory must never be deleted");
+ var content = await File.ReadAllTextAsync(outsideFile);
+ content.Should().Be("CriticalProtectedContent");
+ }
+ finally
+ {
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that when a marker references a backup path outside the designated backup directory,
+ /// undo rejects copying that file into the installation directory.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task Undo_WhenBackupPathIsOutsideBackupDirectory_RejectsBackupAndFailsSafely()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstallBackupOutside");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var destFile = Path.Combine(installationPath, "game_asset.dll");
+ await File.WriteAllTextAsync(destFile, "CurrentInstalledContent");
+
+ var untrustedDir = Path.Combine(_testDirectory, "UntrustedLocation");
+ Directory.CreateDirectory(untrustedDir);
+ var untrustedBackupFile = Path.Combine(untrustedDir, "payload.dll");
+ await File.WriteAllTextAsync(untrustedBackupFile, "UntrustedPayloadContent");
+
+ var markerPath = fix.PublicGetMarkerPath(installation);
+ var markerDir = Path.GetDirectoryName(markerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{untrustedBackupFile}"]);
+
+ try
+ {
+ var result = await fix.UndoAsync(installation);
+
+ result.Success.Should().BeFalse();
+ File.Exists(destFile).Should().BeTrue();
+ var content = await File.ReadAllTextAsync(destFile);
+ content.Should().Be("CurrentInstalledContent", "Destination must not be overwritten from untrusted path outside backup directory");
+ }
+ finally
+ {
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that when all recorded files are restored successfully,
+ /// the backup directory and marker are removed and original content is restored.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task Undo_WhenRestorationSucceeds_RestoresOriginalsAndCleansUp()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstallSuccess");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var backupDir = fix.PublicGetBackupDirectory(installation);
+ Directory.CreateDirectory(backupDir);
+
+ var destFile = Path.Combine(installationPath, "original.ini");
+ var backupFile = Path.Combine(backupDir, "original.ini.bak");
+
+ await File.WriteAllTextAsync(destFile, "ModifiedByPatch");
+ await File.WriteAllTextAsync(backupFile, "OriginalCleanGameContent");
+
+ var markerPath = fix.PublicGetMarkerPath(installation);
+ var markerDir = Path.GetDirectoryName(markerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ await File.WriteAllLinesAsync(markerPath, [$"{destFile}|{backupFile}"]);
+
+ try
+ {
+ var result = await fix.UndoAsync(installation);
+
+ result.Success.Should().BeTrue();
+ File.Exists(destFile).Should().BeTrue();
+ var restoredContent = await File.ReadAllTextAsync(destFile);
+ restoredContent.Should().Be("OriginalCleanGameContent");
+ File.Exists(backupFile).Should().BeFalse();
+ File.Exists(markerPath).Should().BeFalse();
+ }
+ finally
+ {
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ }
+
+ if (Directory.Exists(backupDir))
+ {
+ Directory.Delete(backupDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that ExtractArchiveEntriesAsync successfully extracts multiple entries when their
+ /// cumulative decompressed size is within the allowed aggregate package size budget.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task ExtractArchiveEntriesAsync_WhenEntriesAreWithinAggregateBudget_ExtractsAllEntriesSuccessfullyAsync()
+ {
+ var archivePath = Path.Combine(_testDirectory, "valid_multi_entry.zip");
+ var extractDir = Path.Combine(_testDirectory, "extract_valid");
+ Directory.CreateDirectory(extractDir);
+
+ await CreateValidMultiEntryZipAsync(archivePath);
+
+ using var archive = ArchiveFactory.OpenArchive(archivePath);
+ var extracted = await TestPackageDeploymentFix.PublicExtractArchiveEntriesAsync(archive, extractDir);
+
+ extracted.Should().HaveCount(2);
+ File.Exists(Path.Combine(extractDir, "file1.dat")).Should().BeTrue();
+ File.Exists(Path.Combine(extractDir, "file2.dat")).Should().BeTrue();
+ }
+
+ ///
+ /// Verifies that ExtractArchiveEntriesAsync tracks cumulative extracted bytes across entries and throws
+ /// when the multi-entry total exceeds the aggregate budget.
+ ///
+ /// A representing the test operation.
+ [Fact]
+ public async Task ExtractArchiveEntriesAsync_WhenCumulativeSizeExceedsAggregateBudget_ThrowsArchiveExpansionLimitExceededExceptionAsync()
+ {
+ var archivePath = Path.Combine(_testDirectory, "multi_entry_exceeding_budget.zip");
+ var extractDir = Path.Combine(_testDirectory, "extract_exceeded");
+ Directory.CreateDirectory(extractDir);
+
+ await CreateOversizedMultiEntryZipAsync(archivePath);
+
+ using var archive = ArchiveFactory.OpenArchive(archivePath);
+ var act = () => TestPackageDeploymentFix.PublicExtractArchiveEntriesAsync(archive, extractDir);
+
+ await act.Should().ThrowAsync();
+
+ // Entry 1 was within the remaining budget and completed, whereas entry 2 exceeded the budget and was cleaned up.
+ File.Exists(Path.Combine(extractDir, "entry1.dat")).Should().BeTrue();
+ File.Exists(Path.Combine(extractDir, "entry2.dat")).Should().BeFalse();
+ }
+
+ ///
+ /// Verifies that when a legacy global marker exists and scoped marker is missing, GetMarkerPath migrates
+ /// the global marker to the scoped marker and consumes the legacy global marker to prevent resurrection.
+ ///
+ [Fact]
+ public void GetMarkerPath_WhenLegacyGlobalMarkerExists_MigratesToScopedMarkerAndConsumesGlobalMarker()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstallLegacyMarker");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var scopedMarker = fix.PublicGetMarkerPath(installation);
+ var baseDir = Path.GetDirectoryName(scopedMarker)!;
+ Directory.CreateDirectory(baseDir);
+
+ var globalMarker = Path.Combine(baseDir, "TestPackageDeploymentFix.done");
+ if (File.Exists(scopedMarker))
+ {
+ File.Delete(scopedMarker);
+ }
+
+ File.WriteAllText(globalMarker, "legacy_content");
+
+ try
+ {
+ var resolvedPath = fix.PublicGetMarkerPath(installation);
+
+ resolvedPath.Should().Be(scopedMarker);
+ File.Exists(scopedMarker).Should().BeTrue();
+ File.ReadAllText(scopedMarker).Should().Be("legacy_content");
+ File.Exists(globalMarker).Should().BeFalse("Legacy global marker must be moved to scoped marker to prevent resurrection");
+ }
+ finally
+ {
+ if (File.Exists(scopedMarker))
+ {
+ File.Delete(scopedMarker);
+ }
+
+ if (File.Exists(globalMarker))
+ {
+ File.Delete(globalMarker);
+ }
+ }
+ }
+
+ ///
+ /// Verifies that when rollback executes for a failed batch, it only removes this batch's backup files
+ /// and preserves pre-existing backup files from prior deployments.
+ ///
+ [Fact]
+ public void Rollback_WhenPriorDeploymentBackupsExist_PreservesPriorBackupsInBackupDirectory()
+ {
+ var fix = new TestPackageDeploymentFix(_loggerMock.Object, _httpClientFactoryMock.Object);
+ var installationPath = Path.Combine(_testDirectory, "GameInstallRollback");
+ Directory.CreateDirectory(installationPath);
+ var installation = new GameInstallation(installationPath, GameInstallationType.Steam);
+
+ var backupDir = fix.PublicGetBackupDirectory(installation);
+ Directory.CreateDirectory(backupDir);
+
+ // Pre-existing backup from prior deployment
+ var priorBackupFile = Path.Combine(backupDir, "prior_backup.bak");
+ File.WriteAllText(priorBackupFile, "PriorDeploymentOriginalContent");
+
+ // Current batch deployment entry that needs rollback
+ var destFile = Path.Combine(installationPath, "current_asset.ini");
+ var currentBatchBackupFile = Path.Combine(backupDir, "current_batch_backup.bak");
+ File.WriteAllText(destFile, "CurrentBatchModified");
+ File.WriteAllText(currentBatchBackupFile, "CurrentBatchOriginal");
+
+ var backupEntries = new List<(string DestPath, bool ExistedBefore, string? BackupPath)>
+ {
+ (destFile, true, currentBatchBackupFile),
+ };
+ var details = new List();
+
+ try
+ {
+ fix.PublicRollbackDeployment(backupEntries, backupDir, details);
+
+ // Current batch destination should be restored
+ File.Exists(destFile).Should().BeTrue();
+ File.ReadAllText(destFile).Should().Be("CurrentBatchOriginal");
+
+ // Current batch backup file should be deleted
+ File.Exists(currentBatchBackupFile).Should().BeFalse();
+
+ // Prior deployment backup file must still exist and backup directory must not be deleted
+ Directory.Exists(backupDir).Should().BeTrue("Backup directory must be retained when prior backups exist");
+ File.Exists(priorBackupFile).Should().BeTrue("Prior deployment backup must not be destroyed by failed re-apply rollback");
+ File.ReadAllText(priorBackupFile).Should().Be("PriorDeploymentOriginalContent");
+ }
+ finally
+ {
+ if (Directory.Exists(backupDir))
+ {
+ Directory.Delete(backupDir, true);
+ }
+ }
+ }
+
+ private static async Task CreateValidMultiEntryZipAsync(string archivePath)
+ {
+ using var zipArchive = ZipFile.Open(archivePath, ZipArchiveMode.Create);
+ await WriteZipEntryAsync(zipArchive, "file1.dat", new byte[1024]);
+ await WriteZipEntryAsync(zipArchive, "file2.dat", new byte[2048]);
+ }
+
+ private static async Task CreateOversizedMultiEntryZipAsync(string archivePath)
+ {
+ var chunk = new byte[1024 * 1024];
+ using var zipArchive = ZipFile.Open(archivePath, ZipArchiveMode.Create);
+ await WriteRepeatedChunkZipEntryAsync(zipArchive, "entry1.dat", chunk, 110);
+ await WriteRepeatedChunkZipEntryAsync(zipArchive, "entry2.dat", chunk, 110);
+ }
+
+ private static async Task WriteZipEntryAsync(ZipArchive archive, string entryName, byte[] content)
+ {
+ var entry = archive.CreateEntry(entryName);
+ await using var stream = entry.Open();
+ await stream.WriteAsync(content);
+ }
+
+ private static async Task WriteRepeatedChunkZipEntryAsync(ZipArchive archive, string entryName, byte[] chunk, int repetitions)
+ {
+ var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
+ await using var stream = entry.Open();
+ for (var i = 0; i < repetitions; i++)
+ {
+ await stream.WriteAsync(chunk);
+ }
+ }
+
+ private sealed class TestPackageDeploymentFix(
+ ILogger logger,
+ IHttpClientFactory httpClientFactory,
+ string customId = "TestPackageDeploymentFix")
+ : BasePackageDeploymentFix(httpClientFactory, logger, $"{customId}.done")
+ {
+ public override string Id => customId;
+
+ public override string Title => "Test Package Fix";
+
+ public override string Description => "Test description";
+
+ public override bool IsCoreFix => false;
+
+ public override bool IsCrucialFix => false;
+
+ protected override string PackageDisplayName => "Test Package";
+
+ protected override string TempFilePrefix => "test_pkg";
+
+ protected override string ExpectedSha256 => "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+
+ protected override IReadOnlyList DownloadUrls => ["https://example.com/test.zip"];
+
+ public static Task> PublicExtractArchiveEntriesAsync(
+ IArchive archive,
+ string extractDir,
+ CancellationToken ct = default) => ExtractArchiveEntriesAsync(archive, extractDir, ct);
+
+ public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(true);
+
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default) => Task.FromResult(false);
+
+ public string PublicGetMarkerPath(GameInstallation installation) => GetMarkerPath(installation);
+
+ public string PublicGetBackupDirectory(GameInstallation installation) => GetBackupDirectory(installation);
+
+ public void PublicRollbackDeployment(
+ List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries,
+ string backupDir,
+ List details) => RollbackDeployment(backupEntries, backupDir, details);
+
+ protected override bool AreAssetsPresent(GameInstallation installation) => false;
+
+ protected override List GetLegacyFilePaths(GameInstallation installation) => [];
+
+ protected override Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync(
+ string archivePath,
+ DeploymentContext context,
+ GameInstallation installation,
+ CancellationToken ct)
+ {
+ return Task.FromResult<(int, List?)>((0, []));
+ }
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs
new file mode 100644
index 000000000..884ac9b0b
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/EAAppRegistryFixTests.cs
@@ -0,0 +1,233 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using GenHub.Windows.Features.ActionSets.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Tests for the class.
+///
+public class EAAppRegistryFixTests
+{
+ private readonly Mock _registryMock;
+ private readonly Mock> _loggerMock;
+ private readonly EAAppRegistryFix _fix;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public EAAppRegistryFixTests()
+ {
+ _registryMock = new Mock();
+ _registryMock.Setup(r => r.IsRunningAsAdministrator()).Returns(true);
+
+ // Mock Set operations to return true (success)
+ _registryMock.Setup(r => r.SetStringValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(true);
+ _registryMock.Setup(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(true);
+
+ _loggerMock = new Mock>();
+ _fix = new EAAppRegistryFix(_registryMock.Object, _loggerMock.Object);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true when Generals registry keys are missing.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task IsApplicable_ReturnsTrue_WhenGeneralsKeysMissingAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ ZeroHourPath = "C:\\Games\\Zero Hour",
+ HasGenerals = true,
+ HasZeroHour = true,
+ };
+
+ // Mock Registry: Any call to GetStringValue for Install Path returns null (missing)
+ _registryMock.Setup(r => r.GetStringValue(It.IsAny(), RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns((string?)null);
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true when ergc registry keys are missing.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task IsApplicable_ReturnsTrue_WhenErgcMissingAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ ZeroHourPath = "C:\\Games\\Zero Hour",
+ HasGenerals = true,
+ HasZeroHour = true,
+ };
+
+ // Mock returns correct paths
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns(installation.GeneralsPath);
+ _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny()))
+ .Returns(65544); // 1.08
+
+ // Mock zero hour correct
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns(installation.ZeroHourPath);
+ _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName, It.IsAny()))
+ .Returns(65540); // 1.04
+
+ // Ergc missing (returns empty or null)
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny()))
+ .Returns(string.Empty);
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that ApplyAsync sets the correct registry keys.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task Apply_SetsRegistryKeysAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ ZeroHourPath = "C:\\Games\\Zero Hour",
+ HasGenerals = true,
+ HasZeroHour = true,
+ };
+
+ var result = await _fix.ApplyAsync(installation);
+
+ Assert.True(result.Success);
+
+ // Verify installs - Verify SET usage
+ _registryMock.Verify(r => r.SetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, installation.GeneralsPath, It.IsAny()), Times.Once);
+ _registryMock.Verify(r => r.SetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, RegistryConstants.GeneralsVersionDWord, It.IsAny()), Times.Once);
+
+ // Verify serials logic - should attempt to write if missing (default mock returns null/empty so logic thinks it's missing)
+ _registryMock.Verify(r => r.SetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny(), It.IsAny()), Times.AtLeast(1));
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true for EA App installations.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task IsApplicable_ReturnsTrue_ForEaAppInstallationAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ ZeroHourPath = "C:\\Games\\Zero Hour",
+ HasGenerals = true,
+ HasZeroHour = true,
+ };
+
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns(installation.GeneralsPath);
+ _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny()))
+ .Returns(RegistryConstants.GeneralsVersionDWord);
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny()))
+ .Returns("VALIDSERIAL12345678");
+
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns(installation.ZeroHourPath);
+ _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppZeroHourKeyPath, RegistryConstants.VersionValueName, It.IsAny()))
+ .Returns(RegistryConstants.ZeroHourVersionDWord);
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppZeroHourErgcKeyPath, string.Empty, It.IsAny()))
+ .Returns("VALIDSERIAL87654321");
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns false when installation type is not EA App or Unknown.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task IsApplicable_ReturnsFalse_WhenNotEaAppInstallationAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ HasGenerals = true,
+ };
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.False(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns true when all keys are present and valid, and false otherwise.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task IsApplied_ReturnsTrue_WhenAllKeysValid_AndFalseWhenMissingAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ HasGenerals = true,
+ HasZeroHour = false,
+ };
+
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.InstallPathValueName, It.IsAny()))
+ .Returns(installation.GeneralsPath);
+ _registryMock.Setup(r => r.GetIntValue(RegistryConstants.EAAppGeneralsKeyPath, RegistryConstants.VersionValueName, It.IsAny()))
+ .Returns(RegistryConstants.GeneralsVersionDWord);
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny()))
+ .Returns("VALIDSERIAL");
+
+ var appliedResult = await _fix.IsAppliedAsync(installation);
+ Assert.True(appliedResult);
+
+ // Missing serial
+ _registryMock.Setup(r => r.GetStringValue(RegistryConstants.EAAppGeneralsErgcKeyPath, string.Empty, It.IsAny()))
+ .Returns((string?)null);
+
+ var unappliedResult = await _fix.IsAppliedAsync(installation);
+ Assert.False(unappliedResult);
+ }
+
+ ///
+ /// Verifies that ApplyAsync returns failure when setting registry keys fails.
+ ///
+ /// A representing the asynchronous unit test.
+ [Fact]
+ public async Task Apply_ReturnsFailure_WhenSetStringValueFailsAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.EaApp)
+ {
+ GeneralsPath = "C:\\Games\\Generals",
+ HasGenerals = true,
+ HasZeroHour = false,
+ };
+
+ _registryMock.Setup(r => r.SetStringValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(false);
+
+ var result = await _fix.ApplyAsync(installation);
+
+ Assert.False(result.Success);
+ Assert.Contains("Failed to write", result.ErrorMessage);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs
new file mode 100644
index 000000000..2b1d4196e
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/ExpandedLANLobbyMenuTests.cs
@@ -0,0 +1,258 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class ExpandedLANLobbyMenuTests : IDisposable
+{
+ private readonly Mock _httpClientFactoryMock = new();
+ private readonly Mock> _loggerMock = new();
+ private readonly string _testDir;
+ private readonly ExpandedLanLobbyMenu _fix;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ExpandedLANLobbyMenuTests()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"ExpandedLANLobbyMenuTests_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done");
+ _fix = new ExpandedLanLobbyMenu(_httpClientFactoryMock.Object, _loggerMock.Object, markerPath);
+ }
+
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_testDir))
+ {
+ Directory.Delete(_testDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup failures
+ }
+ }
+
+ ///
+ /// Verifies properties return expected defaults.
+ ///
+ [Fact]
+ public void Properties_ReturnExpectedDefaults()
+ {
+ Assert.Equal("ExpandedLANLobbyMenu", _fix.Id);
+ Assert.Equal("Expanded LAN Lobby Menu (Addon)", _fix.Title);
+ Assert.Equal(ActionSetConstants.Categories.QualityOfLife, _fix.Category);
+ Assert.False(_fix.IsCoreFix);
+ Assert.False(_fix.IsCrucialFix);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true when either game component is present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsApplicableAsync_WhenGeneralsOrZeroHourPresent_ReturnsTrueAsync()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = _testDir,
+ };
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns false when no marker or custom window files exist.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenNoFilesPresent_ReturnsFalseAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.False(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns true when a custom BIG file exists in the installation.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenCustomBigExists_ReturnsTrueAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+ File.WriteAllText(Path.Combine(zhDir, "!ExpandedLANMenu.big"), "content");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that UndoAsync removes recorded custom window files and marker when marker exists.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenMarkerExists_RemovesRecordedFilesAndMarkerAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+ var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big");
+ File.WriteAllText(bigFile, "content");
+
+ var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done");
+ File.WriteAllLines(markerPath, [bigFile]);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(bigFile));
+ Assert.False(File.Exists(markerPath));
+ }
+
+ ///
+ /// Verifies that UndoAsync succeeds when no marker exists and no files are present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNoMarkerExistsAndNoFilesPresent_SucceedsAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Verifies that UndoAsync deletes only recorded files when an unrecorded known file is also present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenMarkerExistsAndUnrecordedFilePresent_LeavesUnrecordedFileIntactAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+ var recordedFile = Path.Combine(zhDir, "!ExpandedLANMenu.big");
+ var unrecordedFile = Path.Combine(zhDir, "CustomWindows.big");
+ File.WriteAllText(recordedFile, "content1");
+ File.WriteAllText(unrecordedFile, "content2");
+
+ var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done");
+ File.WriteAllLines(markerPath, [recordedFile]);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(recordedFile));
+ Assert.True(File.Exists(unrecordedFile));
+ Assert.False(File.Exists(markerPath));
+ }
+
+ ///
+ /// Verifies that UndoAsync returns a warning failure when files are present on disk but no marker exists.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNoMarkerExistsAndFilesPresent_ReturnsWarningFailureAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+ var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big");
+ File.WriteAllText(bigFile, "content");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.False(result.Success);
+ Assert.True(File.Exists(bigFile));
+ Assert.Contains("No deployment marker found", result.ErrorMessage ?? string.Empty);
+ }
+
+ ///
+ /// Verifies that UndoAsync migrates legacy timestamp markers and removes recognized custom window files.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenLegacyTimestampMarkerExists_MigratesAndRemovesRecognizedFilesAsync()
+ {
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(zhDir);
+ var bigFile = Path.Combine(zhDir, "!ExpandedLANMenu.big");
+ File.WriteAllText(bigFile, "content");
+
+ var markerPath = Path.Combine(_testDir, "ExpandedLANLobbyMenu.done");
+ File.WriteAllText(markerPath, "2024-01-01T00:00:00Z");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(bigFile));
+ Assert.False(File.Exists(markerPath));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs
new file mode 100644
index 000000000..5488c9a15
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/FirewallExceptionFixTests.cs
@@ -0,0 +1,58 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class FirewallExceptionFixTests
+{
+ private readonly Mock> _loggerMock = new();
+ private readonly FirewallExceptionFix _fix;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FirewallExceptionFixTests()
+ {
+ _fix = new FirewallExceptionFix(_loggerMock.Object);
+ }
+
+ ///
+ /// Verifies properties return expected defaults.
+ ///
+ [Fact]
+ public void Properties_ReturnExpectedDefaults()
+ {
+ Assert.Equal("FirewallExceptionFix", _fix.Id);
+ Assert.Equal("Windows Firewall Exceptions", _fix.Title);
+ Assert.Equal(ActionSetConstants.Categories.Multiplayer, _fix.Category);
+ Assert.False(_fix.IsCoreFix);
+ Assert.False(_fix.IsCrucialFix);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true for installations with Generals or Zero Hour.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsApplicableAsync_WhenGamePresent_ReturnsTrueAsync()
+ {
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = "C:\\Games\\Generals",
+ };
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs
new file mode 100644
index 000000000..04889c8b8
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/HDIconsFixTests.cs
@@ -0,0 +1,389 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class HDIconsFixTests : IDisposable
+{
+ private readonly Mock _httpClientFactoryMock = new();
+ private readonly Mock> _loggerMock = new();
+ private readonly string _testDir;
+ private readonly HDIconsFix _fix;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public HDIconsFixTests()
+ {
+ _testDir = Path.Combine(Path.GetTempPath(), $"HDIconsFixTests_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_testDir);
+ var markerPath = Path.Combine(_testDir, "HDIconsFix.done");
+ _fix = new HDIconsFix(_httpClientFactoryMock.Object, _loggerMock.Object, markerPath);
+ }
+
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_testDir))
+ {
+ Directory.Delete(_testDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup failures
+ }
+ }
+
+ ///
+ /// Verifies properties return expected defaults.
+ ///
+ [Fact]
+ public void Properties_ReturnExpectedDefaults()
+ {
+ Assert.Equal("HDIconsFix", _fix.Id);
+ Assert.Equal("HD Icons (Addon)", _fix.Title);
+ Assert.Equal(ActionSetConstants.Categories.QualityOfLife, _fix.Category);
+ Assert.False(_fix.IsCoreFix);
+ Assert.False(_fix.IsCrucialFix);
+ }
+
+ ///
+ /// Verifies that IsApplicableAsync returns true when either game component is present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsApplicableAsync_WhenGeneralsOrZeroHourPresent_ReturnsTrueAsync()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = _testDir,
+ };
+
+ var result = await _fix.IsApplicableAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns true when HD icon files exist in the installation directory.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenIconsExist_ReturnsTrueAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ var zhDir = Path.Combine(_testDir, "ZeroHour");
+ Directory.CreateDirectory(genDir);
+ Directory.CreateDirectory(zhDir);
+
+ File.WriteAllText(Path.Combine(genDir, "GeneralsHD.ico"), "icon");
+ File.WriteAllText(Path.Combine(zhDir, "GeneralsZHHD.ico"), "icon");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ HasZeroHour = true,
+ ZeroHourPath = zhDir,
+ };
+
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns false when HD icon files are missing.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenIconsMissing_ReturnsFalseAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.False(result);
+ }
+
+ ///
+ /// Verifies that UndoAsync deletes recorded HD icon files and marker when marker exists.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenMarkerExists_DeletesFilesAndReturnsSuccessAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+ var iconPath = Path.Combine(genDir, "GeneralsHD.ico");
+ File.WriteAllText(iconPath, "icon");
+
+ var markerPath = Path.Combine(_testDir, "HDIconsFix.done");
+ File.WriteAllLines(markerPath, [iconPath]);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(iconPath));
+ Assert.False(File.Exists(markerPath));
+ }
+
+ ///
+ /// Verifies that UndoAsync succeeds when no marker exists and no files are present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNoMarkerExistsAndNoFilesPresent_SucceedsAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents returns false when archive is empty.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_WhenArchiveEmpty_ReturnsFalse()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = _testDir,
+ };
+
+ var result = HDIconsFix.ValidateArchiveContents(new HashSet(), installation);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("HD icons archive contains no valid files.", result.FirstError);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents returns false when Generals icon is missing.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_WhenGeneralsInstalledAndMissingIcon_ReturnsFalse()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = _testDir,
+ };
+
+ var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "Unrecognized.ico" };
+ var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("HD icons package does not contain a recognized icon for Generals.", result.FirstError);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents returns false when Zero Hour icon is missing.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_WhenZeroHourInstalledAndMissingIcon_ReturnsFalse()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = _testDir,
+ };
+
+ var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "Unrecognized.ico" };
+ var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("HD icons package does not contain a recognized icon for Zero Hour.", result.FirstError);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents fails for Zero Hour when only GeneralsHD.ico is present.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_WhenZeroHourInstalledAndOnlyGeneralsIconPresent_ReturnsFalse()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasZeroHour = true,
+ ZeroHourPath = _testDir,
+ };
+
+ var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { "GeneralsHD.ico" };
+ var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation);
+
+ Assert.False(result.IsValid);
+ Assert.Equal("HD icons package does not contain a recognized icon for Zero Hour.", result.FirstError);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents returns true when required icons are present.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_WhenAllRequiredIconsPresent_ReturnsTrue()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = _testDir,
+ HasZeroHour = true,
+ ZeroHourPath = _testDir,
+ };
+
+ var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "GeneralsHD.ico",
+ "GeneralsZHHD.ico",
+ };
+ var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation);
+
+ Assert.True(result.IsValid);
+ Assert.Null(result.FirstError);
+ }
+
+ ///
+ /// Verifies that ValidateArchiveContents matches icons case-insensitively.
+ ///
+ [Fact]
+ public void ValidateArchiveContents_CaseInsensitiveMatching_ReturnsTrue()
+ {
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = _testDir,
+ HasZeroHour = true,
+ ZeroHourPath = _testDir,
+ };
+
+ var archiveFiles = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "generalshd.ico",
+ "generalszhhd.ico",
+ };
+ var result = HDIconsFix.ValidateArchiveContents(archiveFiles, installation);
+
+ Assert.True(result.IsValid);
+ Assert.Null(result.FirstError);
+ }
+
+ ///
+ /// Verifies that UndoAsync deletes only recorded files when an unrecorded known file is also present.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenMarkerExistsAndUnrecordedFilePresent_LeavesUnrecordedFileIntactAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+ var recordedFile = Path.Combine(genDir, "GeneralsHD.ico");
+ var unrecordedFile = Path.Combine(genDir, "game_hd.ico");
+ File.WriteAllText(recordedFile, "icon1");
+ File.WriteAllText(unrecordedFile, "icon2");
+
+ var markerPath = Path.Combine(_testDir, "HDIconsFix.done");
+ File.WriteAllLines(markerPath, [recordedFile]);
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(recordedFile));
+ Assert.True(File.Exists(unrecordedFile));
+ Assert.False(File.Exists(markerPath));
+ }
+
+ ///
+ /// Verifies that UndoAsync returns a warning failure when files are present on disk but no marker exists.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNoMarkerExistsAndFilesPresent_ReturnsWarningFailureAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+ var iconPath = Path.Combine(genDir, "GeneralsHD.ico");
+ File.WriteAllText(iconPath, "icon");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.False(result.Success);
+ Assert.True(File.Exists(iconPath));
+ Assert.Contains("No deployment marker found", result.ErrorMessage ?? string.Empty);
+ }
+
+ ///
+ /// Verifies that UndoAsync migrates legacy timestamp markers and removes recognized icon files.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenLegacyTimestampMarkerExists_MigratesAndRemovesRecognizedFilesAsync()
+ {
+ var genDir = Path.Combine(_testDir, "Generals");
+ Directory.CreateDirectory(genDir);
+ var iconPath = Path.Combine(genDir, "GeneralsHD.ico");
+ File.WriteAllText(iconPath, "icon");
+
+ var markerPath = Path.Combine(_testDir, "HDIconsFix.done");
+ File.WriteAllText(markerPath, "2024-01-01T00:00:00Z");
+
+ var installation = new GameInstallation(_testDir, GameInstallationType.Steam)
+ {
+ HasGenerals = true,
+ GeneralsPath = genDir,
+ };
+
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(iconPath));
+ Assert.False(File.Exists(markerPath));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs
new file mode 100644
index 000000000..1676716ae
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/OneDriveFixTests.cs
@@ -0,0 +1,47 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System.IO;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class OneDriveFixTests
+{
+ private readonly Mock> _loggerMock = new();
+
+ ///
+ /// Verifies properties and basic instantiation.
+ ///
+ [Fact]
+ public void Properties_ReturnExpectedDefaults()
+ {
+ var fix = new OneDriveFix(_loggerMock.Object);
+
+ Assert.Equal("OneDriveFix", fix.Id);
+ Assert.Equal("Prevent OneDrive Sync (Move & Symlink)", fix.Title);
+ Assert.False(fix.IsCoreFix);
+ Assert.False(fix.IsCrucialFix);
+ }
+
+ ///
+ /// Verifies that Undo returns success when no backups exist.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNoBackupsExist_ReturnsSuccessAsync()
+ {
+ var fix = new OneDriveFix(_loggerMock.Object);
+ var installation = new GameInstallation("C:\\TestPath", GameInstallationType.Steam);
+
+ var result = await fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs
new file mode 100644
index 000000000..c66f31632
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/ActionSets/Fixes/PreferIPv4FixTests.cs
@@ -0,0 +1,126 @@
+namespace GenHub.Tests.Windows.Features.ActionSets.Fixes;
+
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Fixes;
+using GenHub.Windows.Features.ActionSets.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+///
+/// Unit tests for .
+///
+public class PreferIPv4FixTests
+{
+ private readonly Mock _registryMock = new();
+ private readonly Mock> _loggerMock = new();
+ private readonly PreferIPv4Fix _fix;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PreferIPv4FixTests()
+ {
+ _registryMock.Setup(r => r.IsRunningAsAdministrator()).Returns(true);
+ _registryMock.Setup(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(true);
+ _registryMock.Setup(r => r.DeleteValue(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(true);
+
+ _fix = new PreferIPv4Fix(_registryMock.Object, _loggerMock.Object);
+ }
+
+ ///
+ /// Verifies properties return expected defaults.
+ ///
+ [Fact]
+ public void Properties_ReturnExpectedDefaults()
+ {
+ Assert.Equal("PreferIPv4Fix", _fix.Id);
+ Assert.Equal("Prefer IPv4", _fix.Title);
+ Assert.Equal(ActionSetConstants.Categories.Multiplayer, _fix.Category);
+ Assert.False(_fix.IsCoreFix);
+ Assert.False(_fix.IsCrucialFix);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns true when DisabledComponents matches expected value.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenRegistryMatches_ReturnsTrueAsync()
+ {
+ _registryMock.Setup(r => r.GetIntValue(
+ RegistryConstants.Tcpip6ParametersKeyPath,
+ RegistryConstants.DisabledComponentsValueName,
+ It.IsAny()))
+ .Returns(RegistryConstants.PreferIPv4DisabledComponentsValue);
+
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true };
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.True(result);
+ }
+
+ ///
+ /// Verifies that IsAppliedAsync returns false when DisabledComponents is missing or 0.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task IsAppliedAsync_WhenRegistryMissing_ReturnsFalseAsync()
+ {
+ _registryMock.Setup(r => r.GetIntValue(
+ RegistryConstants.Tcpip6ParametersKeyPath,
+ RegistryConstants.DisabledComponentsValueName,
+ It.IsAny()))
+ .Returns((int?)null);
+
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true };
+ var result = await _fix.IsAppliedAsync(installation);
+
+ Assert.False(result);
+ }
+
+ ///
+ /// Verifies that ApplyAsync returns success when already configured.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task ApplyAsync_WhenAlreadyApplied_ReturnsSuccessAsync()
+ {
+ _registryMock.Setup(r => r.GetIntValue(
+ RegistryConstants.Tcpip6ParametersKeyPath,
+ RegistryConstants.DisabledComponentsValueName,
+ It.IsAny()))
+ .Returns(RegistryConstants.PreferIPv4DisabledComponentsValue);
+
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true };
+ var result = await _fix.ApplyAsync(installation);
+
+ Assert.True(result.Success);
+ _registryMock.Verify(r => r.SetIntValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies that UndoAsync returns success when not configured.
+ ///
+ /// A representing the asynchronous test.
+ [Fact]
+ public async Task UndoAsync_WhenNotConfigured_ReturnsSuccessAsync()
+ {
+ _registryMock.Setup(r => r.GetIntValue(
+ RegistryConstants.Tcpip6ParametersKeyPath,
+ RegistryConstants.DisabledComponentsValueName,
+ It.IsAny()))
+ .Returns((int?)null);
+
+ var installation = new GameInstallation("C:\\Games", GameInstallationType.Steam) { HasGenerals = true };
+ var result = await _fix.UndoAsync(installation);
+
+ Assert.True(result.Success);
+ _registryMock.Verify(r => r.DeleteValue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs
new file mode 100644
index 000000000..2c2d8cd70
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/AppCompatConfigurationsFix.cs
@@ -0,0 +1,210 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Infrastructure;
+using Microsoft.Extensions.Logging;
+
+///
+/// Fix that applies Windows compatibility flags (Run as Admin, High DPI) for game executables.
+///
+public class AppCompatConfigurationsFix(
+ IRegistryService registryService,
+ ILogger logger) : BaseActionSet(logger)
+{
+ private static readonly IReadOnlyList GeneralsExecutables = ["Generals.exe", "generals.exe", "generalsv.exe"];
+ private static readonly IReadOnlyList ZeroHourExecutables = ["Generals.exe", "generals.exe", "generalszh.exe", "GeneralsOnlineZH.exe", "GeneralsOnlineZH_30.exe", "GeneralsOnlineZH_60.exe"];
+
+ ///
+ public override string Id => "AppCompatConfigurationsFix";
+
+ ///
+ public override string Title => "Windows Compatibility Configurations";
+
+ ///
+ public override string Description => "Sets Windows compatibility flags (RUNASADMIN and HIGHDPIAWARE) to prevent startup crashes and DPI scaling distortion.";
+
+ ///
+ public override string DetailedDescription => "Registers HIGHDPIAWARE and RUNASADMIN flags in the Windows AppCompat registry for all Generals and Zero Hour binaries (automatically differentiating Steam vs. non-Steam installations). This ensures the game renders at native monitor resolution without blurry scaling or privilege errors.";
+
+ ///
+ public override string Category => ActionSetConstants.Categories.CoreAndStability;
+
+ ///
+ public override bool IsCoreFix => true;
+
+ ///
+ public override bool IsCrucialFix => true;
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ string expectedFlag = installation.InstallationType == GameInstallationType.Steam
+ ? "~ HIGHDPIAWARE"
+ : "~ RUNASADMIN HIGHDPIAWARE";
+
+ bool generalsApplied = !installation.HasGenerals || AreFlagsApplied(installation.GeneralsPath, GeneralsExecutables, expectedFlag);
+ bool zhApplied = !installation.HasZeroHour || AreFlagsApplied(installation.ZeroHourPath, ZeroHourExecutables, expectedFlag);
+
+ return Task.FromResult(generalsApplied && zhApplied);
+ }
+
+ ///
+ protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ details.Add("Starting Windows compatibility configuration...");
+
+ string flag = installation.InstallationType == GameInstallationType.Steam
+ ? "~ HIGHDPIAWARE"
+ : "~ RUNASADMIN HIGHDPIAWARE";
+
+ details.Add($"Installation type: {installation.InstallationType}");
+ details.Add($"Compatibility flags: {flag}");
+ details.Add(string.Empty);
+
+ bool allSucceeded = true;
+
+ if (installation.HasGenerals)
+ {
+ details.Add($"Processing Generals executables: {installation.GeneralsPath}");
+ var ok = await ProcessExecutablesAsync(installation.GeneralsPath, GeneralsExecutables, flag, details, ct);
+ if (!ok) allSucceeded = false;
+ }
+
+ if (installation.HasZeroHour)
+ {
+ details.Add($"Processing Zero Hour executables: {installation.ZeroHourPath}");
+ var ok = await ProcessExecutablesAsync(installation.ZeroHourPath, ZeroHourExecutables, flag, details, ct);
+ if (!ok) allSucceeded = false;
+ }
+
+ if (!allSucceeded)
+ {
+ return new ActionSetResult(false, "Failed to apply compatibility flags to one or more executables.", details);
+ }
+
+ details.Add("✓ Windows compatibility configuration completed successfully");
+ return new ActionSetResult(true, null, details);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to apply AppCompat configurations");
+ details.Add($"✗ Error: {ex.Message}");
+ return new ActionSetResult(false, ex.Message, details);
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+ try
+ {
+ details.Add("Removing Windows compatibility registry flags...");
+
+ if (installation.HasGenerals)
+ {
+ foreach (var exe in GeneralsExecutables)
+ {
+ var fullPath = Path.Combine(installation.GeneralsPath, exe);
+ if (registryService.DeleteValue(RegistryConstants.AppCompatLayersKeyPath, fullPath))
+ {
+ details.Add($" ✓ Removed compatibility flags for: {exe}");
+ }
+ }
+ }
+
+ if (installation.HasZeroHour)
+ {
+ foreach (var exe in ZeroHourExecutables)
+ {
+ var fullPath = Path.Combine(installation.ZeroHourPath, exe);
+ if (registryService.DeleteValue(RegistryConstants.AppCompatLayersKeyPath, fullPath))
+ {
+ details.Add($" ✓ Removed compatibility flags for: {exe}");
+ }
+ }
+ }
+
+ details.Add("✓ Compatibility flags removed successfully");
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to undo AppCompat configurations");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ private bool AreFlagsApplied(string? basePath, IReadOnlyList executables, string expectedFlag)
+ {
+ if (string.IsNullOrEmpty(basePath) || !Directory.Exists(basePath))
+ {
+ return true;
+ }
+
+ foreach (var exe in executables)
+ {
+ var fullPath = Path.Combine(basePath, exe);
+ if (File.Exists(fullPath))
+ {
+ var current = registryService.GetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath);
+ if (current != expectedFlag)
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private Task ProcessExecutablesAsync(string installPath, IReadOnlyList executables, string flag, List details, CancellationToken ct)
+ {
+ int processedCount = 0;
+ bool allSucceeded = true;
+
+ foreach (var exe in executables)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var fullPath = Path.Combine(installPath, exe);
+ if (!File.Exists(fullPath)) continue;
+
+ // Set Registry AppCompat Flag
+ try
+ {
+ if (registryService.SetStringValue(RegistryConstants.AppCompatLayersKeyPath, fullPath, flag))
+ {
+ details.Add($" ✓ Set compatibility flags for: {exe}");
+ processedCount++;
+ }
+ else
+ {
+ allSucceeded = false;
+ details.Add($" ✗ Failed to set flags for: {exe}");
+ }
+ }
+ catch (Exception ex)
+ {
+ allSucceeded = false;
+ logger.LogWarning(ex, "Failed to set registry flag for {Path}", fullPath);
+ details.Add($" ✗ Failed to set flags for: {exe}");
+ }
+ }
+
+ details.Add($"✓ Processed {processedCount} executables");
+ return Task.FromResult(allSucceeded);
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs
new file mode 100644
index 000000000..3f42aeb2f
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseExecutableVersionFix.cs
@@ -0,0 +1,167 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+
+///
+/// Abstract base class for executable version verification fixes.
+///
+/// The logger instance.
+public abstract class BaseExecutableVersionFix(ILogger logger) : BaseActionSet(logger)
+{
+ ///
+ public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ return Task.FromResult(HasGame(installation));
+ }
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ try
+ {
+ if (!HasGame(installation))
+ {
+ return Task.FromResult(false);
+ }
+
+ var exePath = FindExecutable(GetGamePath(installation));
+ if (exePath == null)
+ {
+ return Task.FromResult(false);
+ }
+
+ var versionInfo = FileVersionInfo.GetVersionInfo(exePath);
+ var version = versionInfo.FileVersion;
+
+ if (version != null && VersionPrefixes.Any(p => version.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+ {
+ return Task.FromResult(true);
+ }
+
+ return Task.FromResult(false);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error checking {Game} executable version", GameDisplayName);
+ return Task.FromResult(false);
+ }
+ }
+
+ ///
+ /// Gets the display name of the target game.
+ ///
+ protected abstract string GameDisplayName { get; }
+
+ ///
+ /// Gets the expected version string display.
+ ///
+ protected abstract string TargetVersionDisplay { get; }
+
+ ///
+ /// Gets the list of valid version prefixes for this game executable.
+ ///
+ protected abstract IReadOnlyList VersionPrefixes { get; }
+
+ ///
+ /// Gets candidate executable file names to locate in the game directory.
+ ///
+ protected abstract IReadOnlyList CandidateExecutableNames { get; }
+
+ ///
+ /// Checks whether the game installation contains the targeted game.
+ ///
+ /// The targeted game installation.
+ /// true if present; otherwise, false.
+ protected abstract bool HasGame(GameInstallation installation);
+
+ ///
+ /// Gets the path to the game directory.
+ ///
+ /// The targeted game installation.
+ /// The game directory path.
+ protected abstract string? GetGamePath(GameInstallation installation);
+
+ ///
+ protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ if (!HasGame(installation))
+ {
+ details.Add($"✗ {GameDisplayName} is not installed");
+ return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} is not installed in this installation.", details));
+ }
+
+ details.Add($"{GameDisplayName} Executable Fix - Informational");
+ details.Add(string.Empty);
+ details.Add($"This fix ensures the {GameDisplayName} {TargetVersionDisplay} patch is applied.");
+ details.Add(string.Empty);
+
+ var gamePath = GetGamePath(installation);
+ var exePath = FindExecutable(gamePath);
+
+ if (exePath != null)
+ {
+ var versionInfo = FileVersionInfo.GetVersionInfo(exePath);
+ var version = versionInfo.FileVersion;
+
+ details.Add($"Current executable: {Path.GetFileName(exePath)}");
+ details.Add($"Current version: {version ?? "unknown"}");
+
+ if (version != null && VersionPrefixes.Any(p => version.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
+ {
+ details.Add($"✓ {GameDisplayName} {TargetVersionDisplay} patch is already applied");
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+
+ details.Add($"⚠ {GameDisplayName} {TargetVersionDisplay} patch needs to be applied");
+ details.Add(" Please use the appropriate patch in GenHub to update your game client.");
+ return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} executable is not version {TargetVersionDisplay}.", details));
+ }
+
+ details.Add($"⚠ {GameDisplayName} executable not found in: {gamePath}");
+ return Task.FromResult(new ActionSetResult(false, $"{GameDisplayName} executable not found in {gamePath}", details));
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error checking {Game} executable version", GameDisplayName);
+ details.Add($"✗ Error: {ex.Message}");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ Logger.LogWarning("Undoing {Game} Executable Fix is not supported.", GameDisplayName);
+ return Task.FromResult(new ActionSetResult(true));
+ }
+
+ ///
+ /// Finds the first matching executable path in the specified game directory.
+ ///
+ /// The game directory path.
+ /// The path to the located executable, or null if not found.
+ protected string? FindExecutable(string? gamePath)
+ {
+ if (string.IsNullOrEmpty(gamePath) || !Directory.Exists(gamePath))
+ {
+ return null;
+ }
+
+ return CandidateExecutableNames
+ .Select(exe => Path.Combine(gamePath, exe))
+ .FirstOrDefault(File.Exists);
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs
new file mode 100644
index 000000000..f61f8241d
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseFileRenameFix.cs
@@ -0,0 +1,196 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+
+///
+/// Abstract base class for fixes that disable problematic DLLs/files by renaming them to a backup extension.
+///
+public abstract class BaseFileRenameFix(
+ ILogger logger,
+ string targetFileName,
+ string backupFileName)
+ : BaseActionSet(logger)
+{
+ ///
+ public override string Category => ActionSetConstants.Categories.CoreAndStability;
+
+ ///
+ public override bool IsCoreFix => true;
+
+ ///
+ public override bool IsCrucialFix => true;
+
+ ///
+ public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath) && File.Exists(Path.Combine(installation.GeneralsPath, targetFileName)))
+ {
+ return Task.FromResult(true);
+ }
+
+ if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath) && File.Exists(Path.Combine(installation.ZeroHourPath, targetFileName)))
+ {
+ return Task.FromResult(true);
+ }
+
+ return Task.FromResult(false);
+ }
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ bool generalsApplied = !installation.HasGenerals ||
+ string.IsNullOrEmpty(installation.GeneralsPath) ||
+ !File.Exists(Path.Combine(installation.GeneralsPath, targetFileName));
+
+ bool zeroHourApplied = !installation.HasZeroHour ||
+ string.IsNullOrEmpty(installation.ZeroHourPath) ||
+ !File.Exists(Path.Combine(installation.ZeroHourPath, targetFileName));
+
+ return Task.FromResult(generalsApplied && zeroHourApplied);
+ }
+
+ ///
+ protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ details.Add($"Starting {Title}...");
+
+ if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath))
+ {
+ details.Add($"Processing Generals: {installation.GeneralsPath}");
+ if (!RenameFile(installation.GeneralsPath, details))
+ {
+ details.Add($" ⚠ {targetFileName} not found (may already be fixed)");
+ }
+ }
+
+ if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath))
+ {
+ details.Add($"Processing Zero Hour: {installation.ZeroHourPath}");
+ if (!RenameFile(installation.ZeroHourPath, details))
+ {
+ details.Add($" ⚠ {targetFileName} not found (may already be fixed)");
+ }
+ }
+
+ details.Add($"✓ {Title} completed successfully");
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error applying {Title}", Title);
+ details.Add($"✗ Error: {ex.Message}");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ details.Add($"Restoring {targetFileName}...");
+
+ if (installation.HasGenerals && !string.IsNullOrEmpty(installation.GeneralsPath))
+ {
+ details.Add($"Processing Generals: {installation.GeneralsPath}");
+ if (!RestoreFile(installation.GeneralsPath, details))
+ {
+ details.Add($" ⚠ {backupFileName} not found (nothing to restore)");
+ }
+ }
+
+ if (installation.HasZeroHour && !string.IsNullOrEmpty(installation.ZeroHourPath))
+ {
+ details.Add($"Processing Zero Hour: {installation.ZeroHourPath}");
+ if (!RestoreFile(installation.ZeroHourPath, details))
+ {
+ details.Add($" ⚠ {backupFileName} not found (nothing to restore)");
+ }
+ }
+
+ details.Add($"✓ {targetFileName} restoration completed successfully");
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error restoring {TargetFileName}", targetFileName);
+ details.Add($"✗ Error: {ex.Message}");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ private bool RenameFile(string directory, List details)
+ {
+ var originalPath = Path.Combine(directory, targetFileName);
+ var backupPath = Path.Combine(directory, backupFileName);
+
+ if (!File.Exists(originalPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (File.Exists(backupPath))
+ {
+ File.Delete(backupPath);
+ }
+
+ File.Move(originalPath, backupPath);
+ details.Add($" ✓ Renamed: {targetFileName} -> {backupFileName}");
+ Logger.LogInformation("Renamed {OriginalPath} to {BackupPath}", originalPath, backupPath);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Failed to rename {OriginalPath}", originalPath);
+ details.Add($" ✗ Error renaming {targetFileName}: {ex.Message}");
+ return false;
+ }
+ }
+
+ private bool RestoreFile(string directory, List details)
+ {
+ var originalPath = Path.Combine(directory, targetFileName);
+ var backupPath = Path.Combine(directory, backupFileName);
+
+ if (!File.Exists(backupPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ if (File.Exists(originalPath))
+ {
+ File.Delete(originalPath);
+ }
+
+ File.Move(backupPath, originalPath);
+ details.Add($" ✓ Restored: {backupFileName} -> {targetFileName}");
+ Logger.LogInformation("Restored {BackupPath} to {OriginalPath}", backupPath, originalPath);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Failed to restore {BackupPath}", backupPath);
+ details.Add($" ✗ Error restoring {backupFileName}: {ex.Message}");
+ return false;
+ }
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs
new file mode 100644
index 000000000..06b3f9113
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BasePackageDeploymentFix.cs
@@ -0,0 +1,833 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Helpers;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Core.Utilities;
+using Microsoft.Extensions.Logging;
+using SharpCompress.Archives;
+
+///
+/// Abstract base class for downloadable package deployment fixes (e.g., HD Icons, Expanded LAN Lobby).
+/// Handles package download, hash validation, safe materialization with backup tracking, marker persistence, and rollback.
+///
+public abstract class BasePackageDeploymentFix(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger,
+ string defaultMarkerFileName,
+ string? markerPath = null)
+ : BaseActionSet(logger)
+{
+ ///
+ /// Execution context for package deployment operations.
+ ///
+ /// The temporary directory for archive extraction.
+ /// The persistent directory for backing up pre-existing game files.
+ /// The list tracking backup metadata for rollback and undo.
+ /// The list accumulating deployed file paths.
+ /// The diagnostic details list.
+ public record DeploymentContext(
+ string TempExtractDir,
+ string BackupDir,
+ List<(string DestPath, bool ExistedBefore, string? BackupPath)> BackupEntries,
+ List DeployedFiles,
+ List Details);
+
+ ///
+ /// Gets the list of download URLs for the package.
+ ///
+ protected abstract IReadOnlyList DownloadUrls { get; }
+
+ ///
+ /// Gets the expected SHA-256 hash for package verification.
+ ///
+ protected abstract string ExpectedSha256 { get; }
+
+ ///
+ /// Gets the human-readable package name for logs and messages.
+ ///
+ protected abstract string PackageDisplayName { get; }
+
+ ///
+ /// Gets the file prefix used for temporary download files.
+ ///
+ protected abstract string TempFilePrefix { get; }
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ return Task.FromResult(AreAssetsPresent(installation));
+ }
+
+ ///
+ /// Deploys a file with backup tracking, preventing duplicate backups of the same destination path.
+ ///
+ /// The path of the source file to deploy.
+ /// The destination path in the game directory.
+ /// The deployment context.
+ protected static void DeployFileWithBackup(
+ string sourceFilePath,
+ string destPath,
+ DeploymentContext context)
+ {
+ var existingEntryIndex = context.BackupEntries.FindIndex(b => string.Equals(b.DestPath, destPath, StringComparison.OrdinalIgnoreCase));
+ if (existingEntryIndex >= 0)
+ {
+ // Already backed up during this deployment batch; overwrite destination with new file without destroying original backup
+ File.Copy(sourceFilePath, destPath, overwrite: true);
+ return;
+ }
+
+ var existedBefore = File.Exists(destPath);
+ string? backupPath = null;
+
+ if (existedBefore)
+ {
+ Directory.CreateDirectory(context.BackupDir);
+ backupPath = Path.Combine(context.BackupDir, $"{Guid.NewGuid():N}_{Path.GetFileName(destPath)}");
+ File.Copy(destPath, backupPath, overwrite: true);
+ }
+
+ context.BackupEntries.Add((destPath, existedBefore, backupPath));
+
+ var destDir = Path.GetDirectoryName(destPath);
+ if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
+ {
+ Directory.CreateDirectory(destDir);
+ }
+
+ File.Copy(sourceFilePath, destPath, overwrite: true);
+ if (!context.DeployedFiles.Contains(destPath, StringComparer.OrdinalIgnoreCase))
+ {
+ context.DeployedFiles.Add(destPath);
+ }
+ }
+
+ ///
+ /// Collects existing file paths from a directory matching candidate names.
+ ///
+ /// The base directory path.
+ /// The candidate file names.
+ /// The list accumulating found paths.
+ protected static void CollectExistingFiles(string? basePath, IReadOnlyList candidateNames, List output)
+ {
+ if (string.IsNullOrEmpty(basePath) || !Directory.Exists(basePath))
+ {
+ return;
+ }
+
+ output.AddRange(candidateNames
+ .Select(name => Path.Combine(basePath, name))
+ .Where(File.Exists)
+ .Except(output, StringComparer.OrdinalIgnoreCase));
+ }
+
+ ///
+ /// Extracts all non-directory archive entries to the destination directory.
+ ///
+ /// The archive to extract.
+ /// The destination extraction directory.
+ /// Cancellation token.
+ /// A dictionary mapping file name to extracted file path.
+ protected static async Task> ExtractArchiveEntriesAsync(
+ IArchive archive,
+ string extractDir,
+ CancellationToken ct)
+ {
+ var extractedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ long expandedBytes = 0;
+
+ foreach (var entry in archive.Entries.Where(e => !e.IsDirectory && e.Key != null))
+ {
+ ct.ThrowIfCancellationRequested();
+ var fileName = Path.GetFileName(entry.Key);
+ if (string.IsNullOrEmpty(fileName))
+ {
+ continue;
+ }
+
+ var extractedFilePath = Path.Combine(extractDir, fileName);
+ await using var entryStream = await entry.OpenEntryStreamAsync(ct);
+ expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync(
+ entryStream,
+ extractedFilePath,
+ fileName,
+ ActionSetConstants.Validation.MaximumAddonPackageSizeBytes,
+ ActionSetConstants.Validation.MaximumAddonPackageSizeBytes - expandedBytes,
+ overwrite: true,
+ cancellationToken: ct);
+
+ extractedFiles[fileName] = extractedFilePath;
+ }
+
+ return extractedFiles;
+ }
+
+ ///
+ /// Gets the resolved marker path for a specific game installation.
+ ///
+ /// The game installation.
+ /// The absolute marker file path.
+ protected string GetMarkerPath(GameInstallation installation)
+ {
+ if (!string.IsNullOrEmpty(markerPath))
+ {
+ return markerPath;
+ }
+
+ var baseDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "GenHub",
+ ActionSetConstants.Paths.SubActionSetMarkers);
+
+ var key = ComputeInstallationKey(installation);
+ var scopedMarker = Path.Combine(baseDir, $"{Path.GetFileNameWithoutExtension(defaultMarkerFileName)}_{key}{Path.GetExtension(defaultMarkerFileName)}");
+
+ // Backward compatibility: migrate legacy global marker to scoped marker if scoped marker is missing
+ var globalMarker = Path.Combine(baseDir, defaultMarkerFileName);
+ if (!File.Exists(scopedMarker) && File.Exists(globalMarker))
+ {
+ try
+ {
+ var markerDir = Path.GetDirectoryName(scopedMarker);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ File.Move(globalMarker, scopedMarker);
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to migrate legacy global marker {GlobalMarker} to scoped marker {ScopedMarker}", globalMarker, scopedMarker);
+ return globalMarker;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied migrating legacy global marker {GlobalMarker} to scoped marker {ScopedMarker}", globalMarker, scopedMarker);
+ return globalMarker;
+ }
+ }
+
+ return scopedMarker;
+ }
+
+ ///
+ /// Gets the persistent backup directory for saving overwritten files.
+ ///
+ /// The game installation.
+ /// The backup directory path.
+ protected string GetBackupDirectory(GameInstallation installation)
+ {
+ var key = ComputeInstallationKey(installation);
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "GenHub",
+ "Backups",
+ $"{Id}_{key}");
+ }
+
+ ///
+ protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var targetMarkerPath = GetMarkerPath(installation);
+ var persistentBackupDir = GetBackupDirectory(installation);
+ var tempFile = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_{Guid.NewGuid():N}.dat");
+ var tempExtractDir = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_extract_{Guid.NewGuid():N}");
+ var backupEntries = new List<(string DestPath, bool ExistedBefore, string? BackupPath)>();
+ var deployedFiles = new List();
+ var details = new List();
+ var context = new DeploymentContext(tempExtractDir, persistentBackupDir, backupEntries, deployedFiles, details);
+
+ try
+ {
+ details.Add($"Downloading {PackageDisplayName} package...");
+
+ var downloaded = await DownloadPackageAsync(tempFile, details, ct);
+ if (!downloaded)
+ {
+ return new ActionSetResult(false, $"Failed to download {PackageDisplayName} from available sources.", details);
+ }
+
+ var validation = await DownloadSecurityValidator.ValidateFileAsync(
+ tempFile,
+ allowedSha256Hashes: [ExpectedSha256],
+ ct: ct);
+
+ if (!validation.Success)
+ {
+ var errorSummary = string.Join("; ", validation.Errors);
+ Logger.LogWarning("Security validation failed for {Name} package: {Error}", PackageDisplayName, errorSummary);
+ return new ActionSetResult(false, $"Package failed security verification: {errorSummary}", details);
+ }
+
+ details.Add("✓ Package integrity verified via SHA-256 checksum.");
+ details.Add($"Extracting {PackageDisplayName} assets...");
+ Directory.CreateDirectory(tempExtractDir);
+
+ var (extractedCount, deployed) = await ExtractAndDeployAssetsAsync(
+ tempFile,
+ context,
+ installation,
+ ct);
+
+ if (deployed == null)
+ {
+ RollbackDeployment(backupEntries, persistentBackupDir, details);
+ return new ActionSetResult(false, $"Failed to extract and validate {PackageDisplayName} package.", details);
+ }
+
+ details.Add($"✓ Extracted and deployed {extractedCount} assets to game folders.");
+
+ if (!RecordDeploymentMarker(targetMarkerPath, backupEntries))
+ {
+ details.Add("✗ Failed to record the deployment marker. Rolling back deployed files.");
+ RollbackDeployment(backupEntries, persistentBackupDir, details);
+ return new ActionSetResult(false, $"Failed to record the deployment marker for {Id}.", details);
+ }
+
+ return new ActionSetResult(true, null, details);
+ }
+ catch (OperationCanceledException)
+ {
+ RollbackDeployment(backupEntries, persistentBackupDir, details);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ RollbackDeployment(backupEntries, persistentBackupDir, details);
+ Logger.LogError(ex, "Error applying {Name} fix", PackageDisplayName);
+ details.Add($"✗ Error: {ex.Message}");
+ return new ActionSetResult(false, ex.Message, details);
+ }
+ finally
+ {
+ DeleteFileSafely(tempFile);
+ DeleteDirectorySafely(tempExtractDir);
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+ var targetMarkerPath = GetMarkerPath(installation);
+ var persistentBackupDir = GetBackupDirectory(installation);
+
+ try
+ {
+ if (!File.Exists(targetMarkerPath))
+ {
+ if (AreAssetsPresent(installation))
+ {
+ details.Add($"⚠ No deployment marker found. Custom {PackageDisplayName} files may have been installed manually; please remove them manually if desired.");
+ return Task.FromResult(new ActionSetResult(false, "No deployment marker found to undo.", details));
+ }
+
+ return Task.FromResult(new ActionSetResult(true, null, ["No deployment record found to undo."]));
+ }
+
+ var lines = ReadMarkerLinesSafely(targetMarkerPath);
+ if (lines == null)
+ {
+ Logger.LogWarning("Failed to read installed file paths from marker {MarkerPath}", targetMarkerPath);
+ return Task.FromResult(new ActionSetResult(false, "Failed to read deployment marker", ["✗ Could not read deployment marker."]));
+ }
+
+ if (lines.Length == 0)
+ {
+ DeleteFileSafely(targetMarkerPath);
+ DeleteDirectorySafely(persistentBackupDir);
+ return Task.FromResult(new ActionSetResult(true, null, ["No deployment record found to undo."]));
+ }
+
+ var records = ParseMarkerRecords(lines, installation);
+ var (removedCount, restoredCount, restoredBackupPaths, remainingRecords) = RestoreOrDeleteRecordedFiles(
+ records,
+ installation,
+ persistentBackupDir,
+ ct);
+
+ var markerUpdated = UpdateMarkerAfterUndo(targetMarkerPath, remainingRecords);
+ if (!markerUpdated)
+ {
+ details.Add("✗ Failed to update deployment marker after undo. Backups have been retained.");
+ return Task.FromResult(new ActionSetResult(false, "Failed to update deployment marker after undo.", details));
+ }
+
+ // Clean up restored backup files only after the marker update succeeded
+ var remainingBackups = remainingRecords
+ .Where(r => !string.IsNullOrEmpty(r.BackupPath))
+ .Select(r => r.BackupPath)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var backupPath in restoredBackupPaths.Where(b => !remainingBackups.Contains(b)))
+ {
+ DeleteFileSafely(backupPath);
+ }
+
+ if (remainingRecords.Count == 0)
+ {
+ DeleteDirectorySafely(persistentBackupDir);
+ var summary = restoredCount > 0
+ ? $"{PackageDisplayName} removed ({removedCount} files deleted, {restoredCount} originals restored)."
+ : $"{PackageDisplayName} removed ({removedCount} files deleted).";
+ details.Add(summary);
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+
+ details.Add($"⚠ Partial undo: {removedCount} files removed, {restoredCount} restored, {remainingRecords.Count} files could not be processed.");
+ return Task.FromResult(new ActionSetResult(false, $"Failed to remove/restore {remainingRecords.Count} files during undo.", details));
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "I/O error deleting marker or restoring files for {Name}", PackageDisplayName);
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission error deleting marker or restoring files for {Name}", PackageDisplayName);
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ ///
+ /// Extracts archive contents and deploys them to target game directories with backup tracking.
+ ///
+ /// The local path of the downloaded archive.
+ /// The deployment context.
+ /// The targeted game installation.
+ /// The cancellation token.
+ /// A tuple of extracted file count and list of deployed file paths.
+ protected abstract Task<(int ExtractedCount, List? DeployedFiles)> ExtractAndDeployAssetsAsync(
+ string archivePath,
+ DeploymentContext context,
+ GameInstallation installation,
+ CancellationToken ct);
+
+ ///
+ /// Determines whether the deployed assets are present in the game installation.
+ ///
+ /// The game installation to inspect.
+ /// true if all required assets are present; otherwise, false.
+ protected abstract bool AreAssetsPresent(GameInstallation installation);
+
+ ///
+ /// Gets legacy file paths if no absolute paths are present in marker.
+ ///
+ /// The game installation.
+ /// List of candidate legacy asset paths.
+ protected abstract List GetLegacyFilePaths(GameInstallation installation);
+
+ ///
+ /// Downloads the package from available mirror URLs.
+ ///
+ /// The destination temporary file path.
+ /// The diagnostic details list.
+ /// The cancellation token.
+ /// true if download succeeded; otherwise, false.
+ protected async Task DownloadPackageAsync(
+ string tempFile,
+ List details,
+ CancellationToken ct)
+ {
+ using var client = httpClientFactory.CreateClient("Downloader");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
+
+ foreach (var url in DownloadUrls)
+ {
+ try
+ {
+ Logger.LogInformation("Attempting {Name} download from {Url}", PackageDisplayName, url);
+ using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ await DownloadToFileAsync(response, tempFile, ct);
+
+ var fileInfo = new FileInfo(tempFile);
+ if (fileInfo.Length < ActionSetConstants.Validation.MinimumAddonPackageSizeBytes)
+ {
+ Logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes).", url, fileInfo.Length);
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+
+ continue;
+ }
+
+ details.Add($"✓ {PackageDisplayName} package downloaded successfully.");
+ return true;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to download {Name} from {Url}", PackageDisplayName, url);
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Rolls back deployed assets and restores backed-up files upon deployment failure.
+ ///
+ /// The list of backup entries tracked during deployment.
+ /// The persistent backup directory path.
+ /// The diagnostic details list.
+ protected void RollbackDeployment(
+ List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries,
+ string backupDir,
+ List details)
+ {
+ details.Add("Rolling back deployed assets...");
+ var hasRollbackError = false;
+ foreach (var (destPath, existedBefore, backupPath) in backupEntries)
+ {
+ if (!RollbackEntry(destPath, existedBefore, backupPath))
+ {
+ hasRollbackError = true;
+ }
+ }
+
+ if (!hasRollbackError)
+ {
+ CleanupEmptyBackupDirectory(backupDir);
+ details.Add("✓ Rollback completed.");
+ }
+ else
+ {
+ details.Add("⚠ Rollback completed with some file warnings. Backups have been retained for recovery.");
+ }
+ }
+
+ private static async Task DownloadToFileAsync(HttpResponseMessage response, string tempFile, CancellationToken ct)
+ {
+ await using var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
+ await response.Content.CopyToAsync(fs, ct);
+ }
+
+ private static string ComputeInstallationKey(GameInstallation installation)
+ {
+ if (string.IsNullOrEmpty(installation.InstallationPath))
+ {
+ return "default";
+ }
+
+ var bytes = System.Text.Encoding.UTF8.GetBytes(installation.InstallationPath.ToUpperInvariant());
+ return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes))[..12].ToLowerInvariant();
+ }
+
+ private static bool IsPathWithinDirectory(string filePath, string directoryPath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(directoryPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var fullDir = Path.GetFullPath(directoryPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ var fullFile = Path.GetFullPath(filePath);
+ return fullFile.StartsWith(fullDir, StringComparison.OrdinalIgnoreCase);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ catch (PathTooLongException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsValidDestinationPath(string destPath, GameInstallation installation)
+ {
+ if (string.IsNullOrWhiteSpace(destPath) || !Path.IsPathRooted(destPath))
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrWhiteSpace(installation.InstallationPath) &&
+ IsPathWithinDirectory(destPath, installation.InstallationPath))
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(installation.GeneralsPath) &&
+ IsPathWithinDirectory(destPath, installation.GeneralsPath))
+ {
+ return true;
+ }
+
+ if (!string.IsNullOrWhiteSpace(installation.ZeroHourPath) &&
+ IsPathWithinDirectory(destPath, installation.ZeroHourPath))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private List<(string DestPath, string? BackupPath)> ParseMarkerRecords(string[] lines, GameInstallation installation)
+ {
+ var records = new List<(string DestPath, string? BackupPath)>();
+ foreach (var line in lines)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ var parts = line.Split('|');
+ var dest = parts[0].Trim();
+ var backup = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1].Trim() : null;
+ if (!string.IsNullOrEmpty(dest))
+ {
+ records.Add((dest, backup));
+ }
+ }
+
+ var hasRootedPaths = records.Any(r => Path.IsPathRooted(r.DestPath));
+ if (!hasRootedPaths)
+ {
+ var legacyPaths = GetLegacyFilePaths(installation);
+ records = legacyPaths.Select(p => (p, (string?)null)).ToList();
+ }
+
+ return records;
+ }
+
+ private (int RemovedCount, int RestoredCount, List RestoredBackupPaths, List<(string DestPath, string? BackupPath)> RemainingRecords) RestoreOrDeleteRecordedFiles(
+ IEnumerable<(string DestPath, string? BackupPath)> records,
+ GameInstallation installation,
+ string persistentBackupDir,
+ CancellationToken ct)
+ {
+ var removedCount = 0;
+ var restoredCount = 0;
+ var restoredBackupPaths = new List();
+ var remainingRecords = new List<(string DestPath, string? BackupPath)>();
+
+ foreach (var (destPath, backupPath) in records)
+ {
+ ct.ThrowIfCancellationRequested();
+ var trimmedDest = destPath.Trim();
+ if (!IsValidDestinationPath(trimmedDest, installation))
+ {
+ Logger.LogWarning("Skipping recorded destination {FilePath} as it is outside the installation directory", trimmedDest);
+ remainingRecords.Add((trimmedDest, backupPath));
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(backupPath) && !IsPathWithinDirectory(backupPath, persistentBackupDir))
+ {
+ Logger.LogWarning("Skipping recorded backup {BackupPath} as it is outside the backup directory", backupPath);
+ remainingRecords.Add((trimmedDest, backupPath));
+ continue;
+ }
+
+ try
+ {
+ if (!string.IsNullOrEmpty(backupPath))
+ {
+ if (TryRestoreBackup(trimmedDest, backupPath))
+ {
+ restoredBackupPaths.Add(backupPath);
+ restoredCount++;
+ }
+ else
+ {
+ remainingRecords.Add((trimmedDest, backupPath));
+ }
+ }
+ else if (File.Exists(trimmedDest))
+ {
+ DeleteFileSafely(trimmedDest);
+ if (File.Exists(trimmedDest))
+ {
+ remainingRecords.Add((trimmedDest, backupPath));
+ }
+ else
+ {
+ removedCount++;
+ }
+ }
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to restore or delete file {FilePath} during undo", trimmedDest);
+ remainingRecords.Add((trimmedDest, backupPath));
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied restoring or deleting file {FilePath} during undo", trimmedDest);
+ remainingRecords.Add((trimmedDest, backupPath));
+ }
+ }
+
+ return (removedCount, restoredCount, restoredBackupPaths, remainingRecords);
+ }
+
+ private bool TryRestoreBackup(string destPath, string backupPath)
+ {
+ if (!File.Exists(backupPath))
+ {
+ Logger.LogWarning("Recorded backup missing for {FilePath} during undo; retaining destination to prevent data loss.", destPath);
+ return false;
+ }
+
+ var destDir = Path.GetDirectoryName(destPath);
+ if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
+ {
+ Directory.CreateDirectory(destDir);
+ }
+
+ File.Copy(backupPath, destPath, overwrite: true);
+ return true;
+ }
+
+ private bool UpdateMarkerAfterUndo(string targetMarkerPath, IReadOnlyList<(string DestPath, string? BackupPath)> remainingRecords)
+ {
+ if (remainingRecords.Count == 0)
+ {
+ DeleteFileSafely(targetMarkerPath);
+ return !File.Exists(targetMarkerPath);
+ }
+
+ string? tempMarker = null;
+ try
+ {
+ var markerDir = Path.GetDirectoryName(targetMarkerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ tempMarker = Path.Combine(markerDir ?? Path.GetTempPath(), $"{Guid.NewGuid():N}.tmp");
+ var lines = remainingRecords.Select(r => $"{r.DestPath}|{r.BackupPath ?? string.Empty}");
+ File.WriteAllLines(tempMarker, lines);
+ File.Move(tempMarker, targetMarkerPath, overwrite: true);
+ return true;
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to rewrite marker file {MarkerPath} with remaining files", targetMarkerPath);
+ DeleteFileSafely(tempMarker);
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied rewriting marker file {MarkerPath} with remaining files", targetMarkerPath);
+ DeleteFileSafely(tempMarker);
+ return false;
+ }
+ }
+
+ private bool RecordDeploymentMarker(
+ string targetMarkerPath,
+ List<(string DestPath, bool ExistedBefore, string? BackupPath)> backupEntries)
+ {
+ string? tempMarker = null;
+ try
+ {
+ var markerDir = Path.GetDirectoryName(targetMarkerPath);
+ if (!string.IsNullOrEmpty(markerDir))
+ {
+ Directory.CreateDirectory(markerDir);
+ }
+
+ tempMarker = Path.Combine(markerDir ?? Path.GetTempPath(), $"{Guid.NewGuid():N}.tmp");
+ var lines = backupEntries.Select(b => $"{b.DestPath}|{b.BackupPath ?? string.Empty}");
+ File.WriteAllLines(tempMarker, lines);
+ File.Move(tempMarker, targetMarkerPath, overwrite: true);
+ return true;
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to create marker file for {Name}", PackageDisplayName);
+ DeleteFileSafely(tempMarker);
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied creating marker file for {Name}", PackageDisplayName);
+ DeleteFileSafely(tempMarker);
+ return false;
+ }
+ }
+
+ private bool RollbackEntry(string destPath, bool existedBefore, string? backupPath)
+ {
+ try
+ {
+ if (existedBefore)
+ {
+ if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath))
+ {
+ File.Copy(backupPath, destPath, overwrite: true);
+ DeleteFileSafely(backupPath);
+ return true;
+ }
+
+ Logger.LogWarning("Original backup missing for {DestPath} during rollback", destPath);
+ return false;
+ }
+
+ if (File.Exists(destPath))
+ {
+ DeleteFileSafely(destPath);
+ if (File.Exists(destPath))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to restore or remove file during rollback: {Path}", destPath);
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied restoring or removing file during rollback: {Path}", destPath);
+ return false;
+ }
+ }
+
+ private void CleanupEmptyBackupDirectory(string backupDir)
+ {
+ try
+ {
+ if (Directory.Exists(backupDir) && !Directory.EnumerateFileSystemEntries(backupDir).Any())
+ {
+ DeleteDirectorySafely(backupDir);
+ }
+ }
+ catch (IOException ex)
+ {
+ Logger.LogWarning(ex, "Failed to inspect or delete empty backup directory {BackupDir} during rollback", backupDir);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogWarning(ex, "Permission denied inspecting or deleting empty backup directory {BackupDir} during rollback", backupDir);
+ }
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs
new file mode 100644
index 000000000..d15d76af8
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BaseVCRedistFix.cs
@@ -0,0 +1,298 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Security;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Helpers;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+using Microsoft.Win32;
+
+///
+/// Abstract base class for Visual C++ Redistributable fixes.
+/// Manages secure download, digital signature verification, silent execution, and cleanup.
+///
+public abstract class BaseVCRedistFix(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger)
+ : BaseActionSet(logger)
+{
+ ///
+ public override string Category => ActionSetConstants.Categories.CoreAndStability;
+
+ ///
+ public override bool IsCoreFix => true;
+
+ ///
+ public override bool IsCrucialFix => true;
+
+ ///
+ /// Gets the list of download URLs for the redistributable installer.
+ ///
+ protected abstract IReadOnlyList DownloadUrls { get; }
+
+ ///
+ /// Gets the arguments to pass to the installer for silent installation.
+ ///
+ protected abstract string InstallerArguments { get; }
+
+ ///
+ /// Gets the human-readable display name of the redistributable.
+ ///
+ protected abstract string RedistDisplayName { get; }
+
+ ///
+ /// Gets the temporary file prefix for downloads.
+ ///
+ protected abstract string TempFilePrefix { get; }
+
+ ///
+ /// Gets the minimum expected file size in bytes for the installer.
+ ///
+ protected virtual long MinimumFileSizeBytes => ActionSetConstants.Validation.MinimumAddonPackageSizeBytes;
+
+ ///
+ /// Gets the optional collection of pinned SHA-256 hashes.
+ ///
+ protected virtual IReadOnlyList? AllowedSha256Hashes => null;
+
+ ///
+ /// Gets the expected Authenticode publisher substring.
+ ///
+ protected virtual string ExpectedPublisher => ActionSetConstants.Security.MicrosoftPublisher;
+
+ ///
+ /// Checks whether an MSI product code is installed in either 32-bit or 64-bit registry views.
+ ///
+ /// The MSI product GUID.
+ /// True if installed; otherwise false.
+ protected bool IsProductInstalled(string productCode)
+ {
+ try
+ {
+ var uninstallKeyPath = RegistryConstants.UninstallKeyPath;
+ using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
+ using var uninstallKey = baseKey.OpenSubKey(uninstallKeyPath);
+ if (uninstallKey != null)
+ {
+ using var subKey = uninstallKey.OpenSubKey(productCode);
+ if (subKey != null)
+ {
+ return true;
+ }
+ }
+
+ using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
+ using var uninstallKey64 = baseKey64.OpenSubKey(uninstallKeyPath);
+ if (uninstallKey64 != null)
+ {
+ using var subKey64 = uninstallKey64.OpenSubKey(productCode);
+ if (subKey64 != null)
+ {
+ return true;
+ }
+ }
+ }
+ catch (SecurityException ex)
+ {
+ Logger.LogDebug(ex, "Security exception inspecting registry for {ProductCode}", productCode);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ Logger.LogDebug(ex, "Unauthorized access inspecting registry for {ProductCode}", productCode);
+ }
+ catch (IOException ex)
+ {
+ Logger.LogDebug(ex, "I/O error inspecting registry for {ProductCode}", productCode);
+ }
+ catch (ArgumentException ex)
+ {
+ Logger.LogDebug(ex, "Argument exception inspecting registry for {ProductCode}", productCode);
+ }
+ catch (ObjectDisposedException ex)
+ {
+ Logger.LogDebug(ex, "Registry key disposed inspecting registry for {ProductCode}", productCode);
+ }
+
+ return false;
+ }
+
+ ///
+ protected override async Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(), $"{TempFilePrefix}_{Guid.NewGuid():N}.exe");
+ var details = new List();
+ FileStream? lockedStream = null;
+
+ try
+ {
+ details.Add($"Downloading {RedistDisplayName}...");
+
+ var downloaded = await DownloadInstallerAsync(tempFile, ct);
+ if (!downloaded)
+ {
+ return new ActionSetResult(false, $"Failed to download {RedistDisplayName} from all available sources.", details);
+ }
+
+ var fileInfo = new FileInfo(tempFile);
+ var fileSize = fileInfo.Length;
+
+ var securityValidation = await DownloadSecurityValidator.ValidateAndLockFileAsync(
+ tempFile,
+ allowedSha256Hashes: AllowedSha256Hashes,
+ expectedAuthenticodePublisher: ExpectedPublisher,
+ allowExpiredCertificates: true,
+ ct: ct);
+
+ if (!securityValidation.Success || securityValidation.Data == null)
+ {
+ var errorSummary = string.Join("; ", securityValidation.Errors);
+ Logger.LogWarning("Security validation failed for {Name}: {Error}", RedistDisplayName, errorSummary);
+ DeleteFileSafely(tempFile);
+ return new ActionSetResult(false, $"Security validation failed: {errorSummary}", details);
+ }
+
+ lockedStream = securityValidation.Data;
+ await lockedStream.DisposeAsync();
+ lockedStream = null;
+
+ details.Add($"✓ Downloaded and verified {fileSize / 1024.0 / 1024.0:F2} MB");
+ details.Add($"Installing {RedistDisplayName} (silent mode)...");
+ details.Add(" ⚠ This may require administrator privileges");
+ Logger.LogInformation("Installing {Name}...", RedistDisplayName);
+
+ var (success, exitCode, errorMsg) = await RunInstallerProcessAsync(tempFile, InstallerArguments, ct);
+ if (success)
+ {
+ details.Add($"✓ {RedistDisplayName} installed successfully (exit code: {exitCode})");
+ return new ActionSetResult(true, null, details);
+ }
+
+ details.Add($"✗ Installation failed with exit code: {exitCode}");
+ return new ActionSetResult(false, errorMsg ?? $"Installation failed with exit code {exitCode}", details);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error installing {Name}", RedistDisplayName);
+ details.Add($"✗ Error: {ex.Message}");
+ return new ActionSetResult(false, ex.Message, details);
+ }
+ finally
+ {
+ if (lockedStream != null)
+ {
+ await lockedStream.DisposeAsync();
+ }
+
+ DeleteFileSafely(tempFile);
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ return Task.FromResult(new ActionSetResult(
+ true,
+ null,
+ [$"ℹ {RedistDisplayName} is a shared system component and does not need to be uninstalled."]));
+ }
+
+ private static async Task<(bool Success, int ExitCode, string? ErrorMessage)> RunInstallerProcessAsync(
+ string installerPath,
+ string arguments,
+ CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = installerPath,
+ Arguments = arguments,
+ UseShellExecute = true,
+ Verb = "runas",
+ CreateNoWindow = true,
+ };
+
+ Process? process;
+ try
+ {
+ process = Process.Start(psi);
+ if (process == null)
+ {
+ return (false, -1, "Failed to start installer process");
+ }
+ }
+ catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
+ {
+ return (false, 1223, "Installation declined: administrator approval was not granted.");
+ }
+ catch (Win32Exception ex)
+ {
+ return (false, ex.NativeErrorCode, $"Failed to launch installer process: {ex.Message}");
+ }
+
+ using (process)
+ {
+ await process.WaitForExitAsync(ct);
+ var exitCode = process.ExitCode;
+
+ if (exitCode is ProcessConstants.ExitCodeSuccess or ProcessConstants.ExitCodeRebootRequired)
+ {
+ return (true, exitCode, null);
+ }
+
+ return (false, exitCode, $"Installer returned non-zero exit code: {exitCode}");
+ }
+ }
+
+ private async Task DownloadInstallerAsync(string tempFile, CancellationToken ct)
+ {
+ using var client = httpClientFactory.CreateClient("Downloader");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
+
+ foreach (var url in DownloadUrls)
+ {
+ try
+ {
+ Logger.LogInformation("Attempting download from {Url}", url);
+ using var response = await client.GetAsync(url, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
+ {
+ await response.Content.CopyToAsync(fs, ct);
+ }
+
+ var fileInfo = new FileInfo(tempFile);
+ if (fileInfo.Length < MinimumFileSizeBytes)
+ {
+ Logger.LogWarning("Downloaded file from {Url} is too small ({Size} bytes)", url, fileInfo.Length);
+ DeleteFileSafely(tempFile);
+ continue;
+ }
+
+ return true;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Download failed from {Url}", url);
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs
new file mode 100644
index 000000000..dc029b8bd
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/BrowserEngineFix.cs
@@ -0,0 +1,23 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using GenHub.Core.Constants;
+using Microsoft.Extensions.Logging;
+
+///
+/// Fix for the BrowserEngine.dll which causes crashes on modern systems.
+///
+public class BrowserEngineFix(ILogger logger)
+ : BaseFileRenameFix(logger, GameClientConstants.BrowserEngineDll, GameClientConstants.BrowserEngineDllBak)
+{
+ ///
+ public override string Id => "BrowserEngineFix";
+
+ ///
+ public override string Title => "Browser Engine DLL Fix";
+
+ ///
+ public override string Description => "Disables the obsolete BrowserEngine.dll that causes instant crashes during game startup on modern Windows.";
+
+ ///
+ public override string DetailedDescription => "Generals originally bundled an embedded web browser DLL from 2002 to display EA in-game news. On modern Windows, this outdated library triggers memory access violations that crash the game before reaching the main menu. This fix renames BrowserEngine.dll to safely bypass the crash.";
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs
new file mode 100644
index 000000000..c813b8cce
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/CncOnlineLauncherFix.cs
@@ -0,0 +1,239 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.GameInstallations;
+using GenHub.Windows.Features.ActionSets.Infrastructure;
+using Microsoft.Extensions.Logging;
+using Microsoft.Win32;
+
+///
+/// Fix that creates registry entries for C&C Online (Revora) multiplayer service.
+/// This enables the game to properly detect and connect to C&C Online servers.
+///
+public class CncOnlineLauncherFix(
+ IRegistryService registryService,
+ ILogger logger) : BaseActionSet(logger)
+{
+ ///
+ public override string Id => "CncOnlineLauncherFix";
+
+ ///
+ public override string Title => "C&C Online Launcher Fix";
+
+ ///
+ public override string Description => "Configures Revora C&C:Online registry keys so community multiplayer services can detect and launch your game.";
+
+ ///
+ public override string DetailedDescription => "Since EA GameSpy servers were decommissioned, C&C:Online provides the primary multiplayer network for Generals and Zero Hour. This fix writes the necessary installation path and version metadata into the registry so community launcher hooks can direct multiplayer traffic to active community servers.";
+
+ ///
+ public override string Category => ActionSetConstants.Categories.Multiplayer;
+
+ ///
+ public override bool IsCoreFix => false;
+
+ ///
+ public override bool IsCrucialFix => false;
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ try
+ {
+ if (installation.HasGenerals)
+ {
+ var genInstalled = registryService.GetStringValue(
+ RegistryConstants.CncOnlineGeneralsKeyPath,
+ RegistryConstants.InstallPathValueName,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ if (string.IsNullOrEmpty(genInstalled))
+ {
+ return Task.FromResult(false);
+ }
+ }
+
+ if (installation.HasZeroHour)
+ {
+ var zhInstalled = registryService.GetStringValue(
+ RegistryConstants.CncOnlineZeroHourKeyPath,
+ RegistryConstants.InstallPathValueName,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ if (string.IsNullOrEmpty(zhInstalled))
+ {
+ return Task.FromResult(false);
+ }
+ }
+
+ return Task.FromResult(installation.HasGenerals || installation.HasZeroHour);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking C&C Online registry status");
+ return Task.FromResult(false);
+ }
+ }
+
+ ///
+ protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ details.Add("Starting C&C Online registry configuration...");
+ bool allSucceeded = true;
+
+ if (installation.HasGenerals)
+ {
+ allSucceeded &= ConfigureGameEntry(
+ "Generals",
+ RegistryConstants.CncOnlineGeneralsKeyPath,
+ installation.GeneralsPath,
+ RegistryConstants.CncOnlineGeneralsVersion,
+ details);
+ }
+
+ if (installation.HasZeroHour)
+ {
+ allSucceeded &= ConfigureGameEntry(
+ "Zero Hour",
+ RegistryConstants.CncOnlineZeroHourKeyPath,
+ installation.ZeroHourPath,
+ RegistryConstants.CncOnlineZeroHourVersion,
+ details);
+ }
+
+ var basePath = installation.HasGenerals ? installation.GeneralsPath : installation.ZeroHourPath;
+ if (!string.IsNullOrEmpty(basePath))
+ {
+ allSucceeded &= ConfigureMainEntry(basePath, details);
+ }
+
+ if (!allSucceeded)
+ {
+ return Task.FromResult(new ActionSetResult(false, "Failed to write one or more C&C Online registry entries.", details));
+ }
+
+ details.Add("✓ C&C Online registry configuration completed successfully");
+ logger.LogInformation("C&C Online registry fix applied with {DetailCount} actions", details.Count);
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error applying C&C Online registry fix");
+ details.Add($"✗ Error: {ex.Message}");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ ///
+ protected override Task UndoInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ var details = new List();
+
+ try
+ {
+ details.Add("Removing C&C Online registry entries...");
+
+ if (installation.HasGenerals)
+ {
+ registryService.DeleteValue(RegistryConstants.CncOnlineGeneralsKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ registryService.DeleteValue(RegistryConstants.CncOnlineGeneralsKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineGeneralsKeyPath}");
+ }
+
+ if (installation.HasZeroHour)
+ {
+ registryService.DeleteValue(RegistryConstants.CncOnlineZeroHourKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ registryService.DeleteValue(RegistryConstants.CncOnlineZeroHourKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineZeroHourKeyPath}");
+ }
+
+ registryService.DeleteValue(RegistryConstants.CncOnlineKeyPath, RegistryConstants.InstallPathValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ registryService.DeleteValue(RegistryConstants.CncOnlineKeyPath, RegistryConstants.VersionValueName, true, Microsoft.Win32.RegistryHive.CurrentUser);
+ details.Add($"✓ Removed registry entries for HKCU\\{RegistryConstants.CncOnlineKeyPath}");
+
+ return Task.FromResult(new ActionSetResult(true, null, details));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error undoing C&C Online registry fix");
+ return Task.FromResult(new ActionSetResult(false, ex.Message, details));
+ }
+ }
+
+ private bool ConfigureGameEntry(
+ string gameName,
+ string keyPath,
+ string installPath,
+ string version,
+ List details)
+ {
+ details.Add($"Configuring C&C Online for {gameName} at: {installPath}");
+
+ bool ok1 = registryService.SetStringValue(
+ keyPath,
+ RegistryConstants.InstallPathValueName,
+ installPath,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ bool ok2 = registryService.SetStringValue(
+ keyPath,
+ RegistryConstants.VersionValueName,
+ version,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ if (ok1 && ok2)
+ {
+ details.Add($"✓ Created: HKCU\\{keyPath}");
+ details.Add($" • InstallPath = {installPath}");
+ details.Add($" • Version = {version}");
+ logger.LogInformation("Created C&C Online registry entries for {GameName}", gameName);
+ return true;
+ }
+
+ details.Add($"✗ Failed to write C&C Online registry entries for {gameName}");
+ return false;
+ }
+
+ private bool ConfigureMainEntry(string basePath, List details)
+ {
+ details.Add("Creating main C&C Online registry entry...");
+
+ bool ok1 = registryService.SetStringValue(
+ RegistryConstants.CncOnlineKeyPath,
+ RegistryConstants.InstallPathValueName,
+ basePath,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ bool ok2 = registryService.SetStringValue(
+ RegistryConstants.CncOnlineKeyPath,
+ RegistryConstants.VersionValueName,
+ RegistryConstants.CncOnlineVersion,
+ useWow6432Node: true,
+ hive: RegistryHive.CurrentUser);
+
+ if (ok1 && ok2)
+ {
+ details.Add($"✓ Created: HKCU\\{RegistryConstants.CncOnlineKeyPath}");
+ details.Add($" • InstallPath = {basePath}");
+ details.Add($" • Version = {RegistryConstants.CncOnlineVersion}");
+ return true;
+ }
+
+ details.Add("✗ Failed to write main C&C Online registry entries");
+ return false;
+ }
+}
diff --git a/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs
new file mode 100644
index 000000000..be1a2aa3d
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/ActionSets/Fixes/D3D8XDLLCheck.cs
@@ -0,0 +1,148 @@
+namespace GenHub.Windows.Features.ActionSets.Fixes;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Features.ActionSets;
+using GenHub.Core.Models.GameInstallations;
+using Microsoft.Extensions.Logging;
+
+///
+/// Fix that checks for DirectX 8 DLLs required by the game.
+/// This fix verifies that necessary DirectX 8 runtime files are present
+/// and provides guidance if they are missing.
+///
+public class D3D8XdllCheck(ILogger logger) : BaseActionSet(logger)
+{
+ // DirectX 8/9 DLLs that Generals and Zero Hour may require (Retail only)
+ private static readonly IReadOnlyList RequiredDLLs =
+ [
+ "d3d8.dll",
+ "d3d8thk.dll",
+ "d3dx9_43.dll",
+ ];
+
+ ///
+ public override string Id => "D3D8XDLLCheck";
+
+ ///
+ public override string Title => "DirectX 8 DLL Check";
+
+ ///
+ public override string Description => "Scans system directories for legacy DirectX 8/9 runtime DLLs (d3d8.dll, d3dx9_43.dll) required to launch the game.";
+
+ ///
+ public override string DetailedDescription => "Modern Windows systems do not pre-install legacy DirectX 8 and 9 runtime libraries by default. This diagnostic check verifies whether essential graphics binaries (d3d8.dll, d3d8thk.dll, and d3dx9_43.dll) exist in SysWOW64 or the game directory to prevent missing DLL startup errors.";
+
+ ///
+ public override string Category => ActionSetConstants.Categories.Compatibility;
+
+ ///
+ public override bool IsCoreFix => false;
+
+ ///
+ public override bool IsCrucialFix => false;
+
+ ///
+ public override Task IsApplicableAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ return Task.FromResult(installation.HasGenerals || installation.HasZeroHour);
+ }
+
+ ///
+ public override Task IsAppliedAsync(GameInstallation installation, CancellationToken ct = default)
+ {
+ try
+ {
+ var missingDLLs = GetMissingDlls(installation);
+ var allPresent = missingDLLs.Count == 0;
+
+ if (allPresent)
+ {
+ logger.LogInformation("All required DirectX 8 DLLs are present");
+ }
+ else
+ {
+ logger.LogWarning("Missing DirectX 8 DLLs: {DLLs}", string.Join(", ", missingDLLs));
+ }
+
+ return Task.FromResult(allPresent);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking DirectX 8 DLLs");
+ return Task.FromResult(false);
+ }
+ }
+
+ ///
+ protected override Task ApplyInternalAsync(GameInstallation installation, CancellationToken ct)
+ {
+ try
+ {
+ var missingDLLs = GetMissingDlls(installation);
+
+ if (missingDLLs.Count == 0)
+ {
+ logger.LogInformation("All required DirectX 8 DLLs are present. No action needed.");
+ return Task.FromResult(new ActionSetResult(true));
+ }
+
+ logger.LogWarning("The following DirectX 8 DLLs are missing: {Dlls}. Please run DirectXRuntimeFix.", string.Join(", ", missingDLLs));
+
+ return Task.FromResult(new ActionSetResult(false, $"Missing {missingDLLs.Count} DirectX 8 DLL(s). Please run DirectX Runtime Fix to install required runtime libraries.", [$"Missing {missingDLLs.Count} DirectX 8 DLL(s). Please run DirectX Runtime Fix."]));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error checking DirectX 8 DLLs");
+ return Task.FromResult(new ActionSetResult(false, ex.Message));
+ }
+ }
+
+ ///