From ef27f76cd8d269518f6f81785a77c23b1121fc0c Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 17 Aug 2026 12:40:34 -0400 Subject: [PATCH 01/83] fix(macos): clear quarantine from materialized game executables (#354) * fix(macos): clear quarantine from materialized game executables * fix(macos): report quarantine-clearing failures to the caller --- .../Workspace/QuarantineClearingTests.cs | 138 ++++++++++++++++++ .../Features/Workspace/ExecutableFileSwap.cs | 25 +++- .../Features/Workspace/MacOSNativeMethods.cs | 64 ++++++++ .../Strategies/WorkspaceStrategyBase.cs | 11 +- .../Features/Workspace/WorkspaceValidator.cs | 11 +- README.md | 23 +++ 6 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/QuarantineClearingTests.cs create mode 100644 GenHub/GenHub/Features/Workspace/MacOSNativeMethods.cs diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/QuarantineClearingTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/QuarantineClearingTests.cs new file mode 100644 index 000000000..7efa46117 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/QuarantineClearingTests.cs @@ -0,0 +1,138 @@ +using System.Diagnostics; +using GenHub.Features.Workspace; + +namespace GenHub.Tests.Core.Features.Workspace; + +/// +/// Tests that materialized executables do not carry macOS's quarantine attribute. +/// +public sealed class QuarantineClearingTests : IDisposable +{ + private readonly string _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + /// + /// Initializes a new instance of the class. + /// + public QuarantineClearingTests() + { + Directory.CreateDirectory(_tempPath); + } + + /// + /// A quarantined file is the case that matters: it is what a downloaded GenHub + /// propagates onto the engine binary, and what Gatekeeper then refuses to run. + /// + [Fact] + public void TryClearQuarantine_WhenFileIsQuarantined_RemovesTheAttribute() + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + var path = Path.Combine(_tempPath, "engine"); + File.WriteAllText(path, "engine binary"); + SetQuarantine(path); + Assert.True(HasQuarantine(path), "the fixture must start out quarantined"); + + Assert.True(MacOSNativeMethods.TryClearQuarantine(path)); + + Assert.False(HasQuarantine(path)); + } + + /// + /// Most files are never quarantined, so the absent case is the common one and must + /// report success rather than an error. + /// + [Fact] + public void TryClearQuarantine_WhenFileIsNotQuarantined_ReportsSuccess() + { + var path = Path.Combine(_tempPath, "plain"); + File.WriteAllText(path, "not quarantined"); + + Assert.True(MacOSNativeMethods.TryClearQuarantine(path)); + } + + /// + /// The swap is what materialization actually calls, so the attribute must be gone + /// from the file it leaves behind, not merely from the temporary copy. + /// + [Fact] + public void MakeExecutable_LeavesTheSwappedFileWithoutQuarantine() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var path = Path.Combine(_tempPath, "generals"); + File.WriteAllText(path, "engine binary"); + if (OperatingSystem.IsMacOS()) + { + SetQuarantine(path); + } + + var quarantineCleared = ExecutableFileSwap.MakeExecutable(path); + + // Callers log on false, so the reported value has to be accurate and not merely + // a constant the call site would never act on. + Assert.True(quarantineCleared); + Assert.True(File.GetUnixFileMode(path).HasFlag(UnixFileMode.UserExecute)); + Assert.Equal("engine binary", File.ReadAllText(path)); + if (OperatingSystem.IsMacOS()) + { + Assert.False(HasQuarantine(path)); + } + } + + /// + public void Dispose() + { + try + { + Directory.Delete(_tempPath, true); + } + catch (IOException) + { + // Best-effort cleanup for temporary test files. + } + catch (UnauthorizedAccessException) + { + // Best-effort cleanup for temporary test files. + } + + GC.SuppressFinalize(this); + } + + // Applied through xattr rather than a P/Invoke of setxattr: the test should prove the + // production path clears what macOS itself considers quarantine, not merely the bytes + // this test wrote. + private static void SetQuarantine(string path) + { + RunXattr($"-w com.apple.quarantine 0083;00000000;GenHubTests; \"{path}\""); + } + + private static bool HasQuarantine(string path) + { + return RunXattr($"-p com.apple.quarantine \"{path}\"").exitCode == 0; + } + + private static (int exitCode, string output) RunXattr(string arguments) + { + using var process = Process.Start(new ProcessStartInfo("/usr/bin/xattr", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + + // Both streams are read before waiting. Draining only one risks the child + // blocking on a full pipe for the other, which would hang the test run rather + // than fail it. + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + var output = outputTask.GetAwaiter().GetResult(); + errorTask.GetAwaiter().GetResult(); + process.WaitForExit(); + return (process.ExitCode, output); + } +} diff --git a/GenHub/GenHub/Features/Workspace/ExecutableFileSwap.cs b/GenHub/GenHub/Features/Workspace/ExecutableFileSwap.cs index d9465eaaf..61ea5c1ec 100644 --- a/GenHub/GenHub/Features/Workspace/ExecutableFileSwap.cs +++ b/GenHub/GenHub/Features/Workspace/ExecutableFileSwap.cs @@ -36,7 +36,13 @@ internal static class ExecutableFileSwap /// Swaps the file at for an executable private copy. /// /// The absolute path of the workspace file. - internal static void MakeExecutable(string targetPath) + /// + /// true when the resulting file is known not to be quarantined. false + /// means the file is executable but macOS may still refuse to run it, which callers + /// should report — it is the difference between a working profile and a game that + /// will not start for a reason nothing else explains. + /// + internal static bool MakeExecutable(string targetPath) { var temporaryPath = targetPath + TemporaryMarker + Guid.NewGuid().ToString("N"); @@ -51,7 +57,24 @@ internal static void MakeExecutable(string targetPath) File.SetUnixFileMode(temporaryPath, ExecutableMode); } + // On macOS the execute bit alone is not enough. A GenHub that was itself + // downloaded carries com.apple.quarantine, and macOS propagates that to the + // files it writes — so the engine binary lands quarantined and Gatekeeper + // refuses to run it. The user sees GenHub start normally and the game fail, + // which is a hard failure to attribute. Clearing it here, on the private copy + // GenHub just created, keeps that invisible to them. + // + // Cleared before the swap for the same reason the mode is: the destination is + // never observable in a half-prepared state. + var quarantineCleared = MacOSNativeMethods.TryClearQuarantine(temporaryPath); + File.Move(temporaryPath, targetPath, overwrite: true); + + // Not an exception: the file is materialized and correct, and failing the + // whole swap would be worse than a file that needs one manual command. The + // caller logs it so the cause is recoverable from the logs when a launch is + // later refused. + return quarantineCleared; } catch { diff --git a/GenHub/GenHub/Features/Workspace/MacOSNativeMethods.cs b/GenHub/GenHub/Features/Workspace/MacOSNativeMethods.cs new file mode 100644 index 000000000..6b805f5bc --- /dev/null +++ b/GenHub/GenHub/Features/Workspace/MacOSNativeMethods.cs @@ -0,0 +1,64 @@ +using System; +using System.Runtime.InteropServices; + +namespace GenHub.Features.Workspace; + +/// +/// The libc calls GenHub needs on macOS only. +/// +/// Separate from by design. That type is restricted to +/// functions whose signatures are identical on Linux and macOS; removexattr is +/// not one of them, because macOS takes a trailing options argument that Linux +/// does not. Declaring it there would be wrong on Linux in a way the compiler cannot +/// catch. +/// +/// +internal static partial class MacOSNativeMethods +{ + /// + /// The extended attribute macOS sets on files that arrived from an untrusted source. + /// Gatekeeper refuses to execute anything carrying it until the user approves. + /// + private const string QuarantineAttribute = "com.apple.quarantine"; + + /// Act on the symlink itself rather than its target. + private const int XattrNoFollow = 0x0001; + + /// The attribute was not present, POSIX ENOATTR on macOS. + private const int ENOATTR = 93; + + /// + /// Removes the quarantine attribute from a file, if it carries one. + /// + /// The absolute path of the file to clear. + /// + /// true when the file is known not to be quarantined afterwards — either the + /// attribute was removed or it was never there. false when the attribute could + /// not be removed, which leaves the file executable-but-blocked. + /// + /// + /// Returns true unchanged on every non-macOS platform: no other system has this + /// attribute, so there is nothing to clear and nothing to report. + /// + internal static bool TryClearQuarantine(string path) + { + if (!OperatingSystem.IsMacOS()) + { + return true; + } + + // Follow symlinks is wrong here: the workspace entry is what has to be runnable, + // and clearing a link target would touch a file this workspace may not own. + if (RemoveExtendedAttribute(path, QuarantineAttribute, XattrNoFollow) == 0) + { + return true; + } + + // Nothing to remove is the common case and not a failure — most files are never + // quarantined, and a build run from a developer machine never is. + return Marshal.GetLastPInvokeError() == ENOATTR; + } + + [LibraryImport("libc", EntryPoint = "removexattr", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + private static partial int RemoveExtendedAttribute(string path, string name, int options); +} diff --git a/GenHub/GenHub/Features/Workspace/Strategies/WorkspaceStrategyBase.cs b/GenHub/GenHub/Features/Workspace/Strategies/WorkspaceStrategyBase.cs index 31feb35c7..064617cd9 100644 --- a/GenHub/GenHub/Features/Workspace/Strategies/WorkspaceStrategyBase.cs +++ b/GenHub/GenHub/Features/Workspace/Strategies/WorkspaceStrategyBase.cs @@ -644,7 +644,16 @@ protected async Task EnsureExecutableAsync(ManifestFile file, string targetPath, // A delete-then-move sequence would expose both of those states. The second // is only papered over later — validation can restore a lost execute bit on // the entry point, but not on any other executable the manifest names. - await Task.Run(() => ExecutableFileSwap.MakeExecutable(targetPath), cancellationToken); + var quarantineCleared = await Task.Run( + () => ExecutableFileSwap.MakeExecutable(targetPath), + cancellationToken); + if (!quarantineCleared) + { + Logger.LogWarning( + "Could not clear the macOS quarantine attribute from {RelativePath}; " + + "macOS may refuse to launch it until it is cleared manually", + file.RelativePath); + } Logger.LogDebug("Marked {RelativePath} executable on a workspace-owned copy", file.RelativePath); } diff --git a/GenHub/GenHub/Features/Workspace/WorkspaceValidator.cs b/GenHub/GenHub/Features/Workspace/WorkspaceValidator.cs index 48fff711f..abe557db5 100644 --- a/GenHub/GenHub/Features/Workspace/WorkspaceValidator.cs +++ b/GenHub/GenHub/Features/Workspace/WorkspaceValidator.cs @@ -360,7 +360,16 @@ public async Task> EnsureEntryPointExecutableAsync(Workspa try { - await Task.Run(() => ExecutableFileSwap.MakeExecutable(executablePath), cancellationToken); + var quarantineCleared = await Task.Run( + () => ExecutableFileSwap.MakeExecutable(executablePath), + cancellationToken); + if (!quarantineCleared) + { + logger.LogWarning( + "Could not clear the macOS quarantine attribute from workspace entry point {ExecutablePath}; " + + "macOS may refuse to launch it until it is cleared manually", + executablePath); + } } catch (Exception ex) { diff --git a/README.md b/README.md index b7da40548..7014efb47 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,29 @@ Launcher for C&C: Generals and Zero Hour with patch management and mod support - [ ] Comprehensive mod support with easy installation - [ ] Compatibility fixes for Windows 10/11 +## Installing on macOS + +GenHub is not signed with an Apple Developer ID, so macOS quarantines it after +download and Gatekeeper refuses to open it. Clear the quarantine attribute once, +before the first launch: + +```sh +xattr -dr com.apple.quarantine /Applications/GenHub.app +``` + +You can instead open **System Settings → Privacy & Security**, find the blocked-app +notice after a failed launch attempt, and choose **Open Anyway**. The Control-click → +*Open* shortcut no longer works for unsigned apps; Apple removed it in macOS 15. + +Prefer the command. macOS propagates quarantine from a quarantined application to the +files it writes, and if GenHub is still marked when it first runs, that can reach the +game files it prepares. GenHub clears the attribute from the game executables it +materializes, so the game itself launches either way — but clearing it on the app up +front avoids the situation entirely. + +None of this applies to a build you compiled yourself. Quarantine is only attached to +downloaded files. + ## Documentation For detailed documentation and guides, visit our [Wiki](https://generalshub.netlify.app/wiki/). From a70b3dbf98367aa26c53ed221f9b31d6fde66b07 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Mon, 17 Aug 2026 13:05:38 -0400 Subject: [PATCH 02/83] fix(generalsonline): forward-port Easy Anti-Cheat launch support from alpha 4 (#366) * fix(generalsonline): forward-port Easy Anti-Cheat launch support from alpha 4 * fix(launching): harden publisher classification and launcher exit probing against review findings --- .../Constants/GameClientConstants.cs | 22 +- .../GenHub.Core/Constants/ProcessConstants.cs | 25 + .../Helpers/GameProcessSelector.cs | 75 +++ .../Helpers/LaunchEntryPointResolver.cs | 36 ++ .../Launching/GameLaunchConfiguration.cs | 15 + .../Models/Launching/GameProcessCandidate.cs | 15 + .../Constants/GameClientHashRegistryTests.cs | 12 + .../GeneralsOnlineClientIdentifierTests.cs | 55 ++ .../GeneralsOnlineManifestFactoryEacTests.cs | 186 +++++++ .../GameClients/GameClientDetectorTests.cs | 234 ++++++++- .../GameProfiles/GameProcessManagerTests.cs | 390 ++++++++++++++ .../Helpers/GameProcessSelectorTests.cs | 161 ++++++ .../Helpers/LaunchEntryPointResolverTests.cs | 74 +++ .../GeneralsOnlineClientIdentifier.cs | 20 +- .../GeneralsOnlineManifestFactory.cs | 27 +- .../GameClients/GameClientDetector.cs | 171 ++++++- .../GameClients/GameClientHashRegistry.cs | 1 + .../Infrastructure/GameProcessManager.cs | 480 ++++++++++++++---- .../GenHub/Features/Launching/GameLauncher.cs | 21 + 19 files changed, 1880 insertions(+), 140 deletions(-) create mode 100644 GenHub/GenHub.Core/Helpers/GameProcessSelector.cs create mode 100644 GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs create mode 100644 GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/LaunchEntryPointResolverTests.cs diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index 4260feb43..2d83a81a2 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -69,6 +69,15 @@ public static class GameClientConstants /// GeneralsOnline default client executable name. public const string GeneralsOnlineDefaultExecutable = "generalsonlinezh.exe"; + /// + /// Easy Anti-Cheat bootstrapper shipped since GeneralsOnline 060526_QFE1. It launches the + /// binary named by EasyAntiCheat/Settings.json and is the supported launch target. + /// + public const string GeneralsOnlineEacLauncherExecutable = "EAC_LaunchGeneralsOnline.exe"; + + /// Epic Online Services Easy Anti-Cheat installer shipped in the GeneralsOnline portable. + public const string GeneralsOnlineEacSetupExecutable = "EasyAntiCheat_EOS_Setup.exe"; + /// Display name for GeneralsOnline 60Hz variant. public const string GeneralsOnline60HzDisplayName = "GeneralsOnline 60Hz"; @@ -180,12 +189,19 @@ public static class GameClientConstants ]; /// - /// List of GeneralsOnline executable names to detect. - /// Only includes 30Hz and 60Hz variants as these are the primary clients. - /// GeneralsOnline provides auto-updated clients for Command & Conquer Generals and Zero Hour. + /// The GeneralsOnline executable names that are supported launch entry points. + /// Since 060526_QFE1 the Easy Anti-Cheat bootstrapper starts the binary named by + /// EasyAntiCheat/Settings.json; older packages launch the 60Hz binary directly. + /// GeneralsOnlineZH.exe ships alongside both but is not wrapped, so it is workspace + /// content rather than an entry point. /// + /// + /// Membership only. When both are present the bootstrapper wins, but that precedence is + /// expressed in the resolving code rather than by the order of this list. + /// public static readonly IReadOnlyList GeneralsOnlineExecutableNames = [ + GeneralsOnlineEacLauncherExecutable, GeneralsOnline60HzExecutable, ]; diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs index 1a438e8d5..01d22c943 100644 --- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs +++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs @@ -82,4 +82,29 @@ public static class ProcessConstants /// Threshold in seconds to consider a process exit as "early" or "immediate". /// public const double EarlyExitThresholdSeconds = 10.0; + + /// + /// How long to wait for a launcher's expected child process to appear. Measured spawn latency + /// for the Easy Anti-Cheat bootstrapper is well under two seconds. Must not exceed + /// , which bounds how old an adoptable process may be. + /// + public const int SpawnedChildDiscoveryTimeoutMs = 10_000; + + /// + /// Interval in milliseconds between polls for a launcher's expected child process. + /// + public const int SpawnedChildPollIntervalMs = 100; + + /// + /// How long to wait for an abandoned launcher to exit after it is killed. The launcher is + /// already being torn down on a cancelled launch, so this only bounds the cleanup. + /// + public const int AbandonedLauncherKillWaitMs = 2_000; + + /// + /// How long to keep polling for the expected child after the launcher itself exits cleanly. + /// Covers the race between the child being spawned and becoming enumerable, without waiting + /// out once the launcher is known to be gone. + /// + public const int LauncherExitGracePeriodMs = 1_000; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs new file mode 100644 index 000000000..4f81bef0c --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; + +namespace GenHub.Core.Helpers; + +/// +/// Decides which running process is the game a launch spawned. +/// +public static class GameProcessSelector +{ + /// + /// Selects the process matching that this launch spawned. + /// + /// The processes currently observed on the machine. + /// The expected process name, without extension. + /// The directory the game must run from, or to skip the check. + /// The current time, used to apply the recency window. + /// The selected candidate, or when none qualifies. + public static GameProcessCandidate? SelectSpawnedGameProcess( + IEnumerable candidates, + string processName, + string? workingDirectory, + DateTime now) + { + var matches = candidates + .Where(candidate => candidate.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) + .Where(candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds); + + // Residence is required whenever a working directory is known, including for a lone match: + // a same-named process elsewhere on the machine is somebody else's. + if (!string.IsNullOrEmpty(workingDirectory)) + { + matches = matches.Where(candidate => ResidesIn(candidate, workingDirectory)); + } + + return matches + .OrderByDescending(candidate => candidate.StartTime) + .FirstOrDefault(); + } + + private static bool ResidesIn(GameProcessCandidate candidate, string workingDirectory) + { + if (candidate.ExecutablePath is null) + { + return false; + } + + var directory = Path.GetDirectoryName(candidate.ExecutablePath); + return directory != null && Normalize(directory).Equals(Normalize(workingDirectory), StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string path) + { + // MainModule.FileName is always absolute and fully resolved, while the configured working + // directory is neither guaranteed. Canonicalize first so a relative spelling or a "." + // segment does not read as a different directory and abandon an adoptable process. + try + { + path = Path.GetFullPath(path); + } + catch (Exception) + { + // A malformed path compares on its original spelling rather than aborting the scan. + } + + return path + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/') + .TrimEnd('/'); + } +} diff --git a/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs b/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs new file mode 100644 index 000000000..89c9d67c8 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using GenHub.Core.Constants; + +namespace GenHub.Core.Helpers; + +/// +/// Relates a launch entry point to the process that ends up owning the game session. +/// +public static class LaunchEntryPointResolver +{ + /// + /// Resolves the process that is expected to spawn and hand + /// the session to. + /// + /// The executable being launched. + /// + /// The expected child process name without extension, or when the + /// launched executable is itself the game. + /// + public static string? ResolveExpectedChildProcessName(string? executablePath) + { + if (string.IsNullOrEmpty(executablePath)) + { + return null; + } + + var fileName = Path.GetFileName(executablePath); + if (fileName.Equals(GameClientConstants.GeneralsOnlineEacLauncherExecutable, StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFileNameWithoutExtension(GameClientConstants.GeneralsOnline60HzExecutable); + } + + return null; + } +} diff --git a/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs b/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs index 656b6d78f..af91d7216 100644 --- a/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs @@ -20,4 +20,19 @@ public class GameLaunchConfiguration /// Gets or sets the timeout for waiting. public TimeSpan? Timeout { get; set; } + + /// + /// Gets or sets the process name, without extension, that is + /// expected to spawn and hand the session to — the Easy Anti-Cheat bootstrapper being the + /// case that needs it. Leave when the started executable *is* the game; + /// tracking then follows the started process as before. + /// + public string? ExpectedChildProcessName { get; set; } + + /// + /// Gets or sets how long to wait for to appear before + /// failing the launch. Defaults to + /// . + /// + public TimeSpan? ExpectedChildDiscoveryTimeout { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs b/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs new file mode 100644 index 000000000..5e80a9dad --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs @@ -0,0 +1,15 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// A running process reduced to the facts needed to decide whether it is the game a launch spawned. +/// Keeps the selection policy free of so it can be tested. +/// +/// The operating system process identifier. +/// The process name, without extension. +/// When the process started. +/// The full image path, or when it cannot be read. +public sealed record GameProcessCandidate( + int ProcessId, + string ProcessName, + DateTime StartTime, + string? ExecutablePath); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs index 2654792ee..9218f9e01 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs @@ -82,6 +82,18 @@ public void PossibleExecutableNames_AreConfigured() Assert.Contains("generalsonlinezh_60.exe", names); } + /// + /// Since 060526_QFE1 the GeneralsOnline portable launches through the Easy Anti-Cheat + /// bootstrapper, so directory scans have to recognise it as a client executable. + /// + [Fact] + public void PossibleExecutableNames_IncludeTheGeneralsOnlineAntiCheatBootstrapper() + { + Assert.Contains( + _registry.PossibleExecutableNames, + name => name.Equals(GameClientConstants.GeneralsOnlineEacLauncherExecutable, StringComparison.OrdinalIgnoreCase)); + } + /// /// Verifies that GameClientInfo.Validate() works correctly. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs new file mode 100644 index 000000000..1fd756ad7 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs @@ -0,0 +1,55 @@ +using GenHub.Core.Constants; +using GenHub.Features.Content.Services.GeneralsOnline; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Tests for across the pre- and post-EAC layouts. +/// +public class GeneralsOnlineClientIdentifierTests +{ + /// + /// The Easy Anti-Cheat bootstrapper is the supported entry point, so publisher discovery + /// must recognise it. + /// + [Fact] + public void Identify_EacLauncher_ReturnsSixtyHertzClient() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnlineEacLauncherExecutable); + + Assert.True(identifier.CanIdentify(path)); + + var identification = identifier.Identify(path); + + Assert.NotNull(identification); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, identification!.DisplayName); + } + + /// + /// Pre-EAC packages ship the 60Hz binary as the entry point and must still be recognised. + /// + [Fact] + public void Identify_SixtyHertzExecutable_ReturnsSixtyHertzClient() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnline60HzExecutable); + + Assert.True(identifier.CanIdentify(path)); + Assert.NotNull(identifier.Identify(path)); + } + + /// + /// Easy Anti-Cheat wraps only the 60Hz binary. The ordinary client binary ships alongside it + /// as workspace content and is not a supported entry point. + /// + [Fact] + public void Identify_DefaultExecutable_IsNotRecognised() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnlineDefaultExecutable); + + Assert.False(identifier.CanIdentify(path)); + Assert.Null(identifier.Identify(path)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs new file mode 100644 index 000000000..08d863978 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -0,0 +1,186 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Tests covering the Easy Anti-Cheat era layout of the Generals Online portable, +/// where EAC_LaunchGeneralsOnline.exe wraps the game binary named by +/// EasyAntiCheat/Settings.json. +/// +public class GeneralsOnlineManifestFactoryEacTests : IDisposable +{ + private readonly string _extractedDirectory; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineManifestFactoryEacTests() + { + _extractedDirectory = Path.Combine(Path.GetTempPath(), $"genhub-eac-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_extractedDirectory); + } + + /// + /// The EAC bootstrapper is the launch target, so it must be the file carrying + /// in the game client manifest. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_MarksWrapperAsExecutable() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + Path.GetFileName(executable.RelativePath), + ignoreCase: true); + } + + /// + /// Easy Anti-Cheat launches the binary named by its settings file, so the wrapped + /// game binary must remain in the workspace as a non-launch file. Dropping it + /// leaves the bootstrapper with nothing to start. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_RetainsWrappedBinaryAsWorkspaceFile() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var wrapped = gameClient.Files.SingleOrDefault(file => + Path.GetFileName(file.RelativePath) + .Equals(GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase)); + + Assert.NotNull(wrapped); + Assert.False(wrapped!.IsExecutable); + } + + /// + /// The portable also ships a non-60Hz binary. Easy Anti-Cheat wraps only the binary named + /// by its settings file, so the other one stays as plain workspace content. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_RetainsDefaultBinaryAsWorkspaceFile() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var defaultBinary = gameClient.Files.SingleOrDefault(file => + Path.GetFileName(file.RelativePath) + .Equals(GameClientConstants.GeneralsOnlineDefaultExecutable, StringComparison.OrdinalIgnoreCase)); + + Assert.NotNull(defaultBinary); + Assert.False(defaultBinary!.IsExecutable); + } + + /// + /// Only the bootstrapper at the archive root is the supported entry point. A nested file + /// that merely shares its name must not divert the launch target away from the real client. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_NestedWrapperName_DoesNotBecomeLaunchTarget() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile(Path.Combine("tools", GameClientConstants.GeneralsOnlineEacLauncherExecutable)); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnline60HzExecutable, + executable.RelativePath, + ignoreCase: true); + } + + /// + /// Pre-EAC portables ship no bootstrapper, so the 60Hz binary stays the launch target. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSixtyHertzBinaryAsExecutable() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnline60HzExecutable, + Path.GetFileName(executable.RelativePath), + ignoreCase: true); + } + + /// + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_extractedDirectory)) + { + Directory.Delete(_extractedDirectory, recursive: true); + } + } + + private static ContentManifest CreateOriginalManifest() => new() + { + Id = "1.605261.generalsonline.gameclient.60hz", + Name = "GeneralsOnline", + Version = "060526_QFE1", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo + { + Name = "GeneralsOnline", + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + + private void WriteEacPortableLayout() + { + WriteFile(GameClientConstants.GeneralsOnlineEacLauncherExecutable); + WriteFile(GameClientConstants.GeneralsOnlineEacSetupExecutable); + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile(GameClientConstants.GeneralsOnlineDefaultExecutable); + WriteFile(Path.Combine("EasyAntiCheat", "Settings.json")); + WriteFile("EOSSDK-Win32-Shipping.dll"); + } + + private void WriteFile(string relativePath) + { + var fullPath = Path.Combine(_extractedDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, relativePath); + } + + private async Task CreateGameClientManifestAsync() + { + var providerLoader = new Mock(); + var factory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + providerLoader.Object); + + var manifests = await factory.CreateManifestsFromExtractedContentAsync( + CreateOriginalManifest(), + _extractedDirectory); + + return manifests.Single(manifest => manifest.ContentType == ContentType.GameClient); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs index 90f9aa908..a09372c18 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs @@ -8,6 +8,7 @@ using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.GeneralsOnline; using GenHub.Features.GameClients; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -19,7 +20,13 @@ namespace GenHub.Tests.Core.Features.GameClients; /// public class GameClientDetectorTests : IDisposable { - private static readonly IReadOnlyList PossibleExecutableNames = [GameClientConstants.GeneralsExecutable, GameClientConstants.GeneralsOnline60HzExecutable]; + private static readonly IReadOnlyList PossibleExecutableNames = + [ + GameClientConstants.GeneralsExecutable, + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + GameClientConstants.GeneralsOnline60HzExecutable, + ]; + private readonly Mock _manifestGenerationServiceMock; private readonly Mock _contentManifestPoolMock; private readonly Mock _hashProviderMock; @@ -305,6 +312,178 @@ public async Task ScanDirectoryForGameClientsAsync_WithUnknownHash_CreatesUnknow Assert.Contains("Unknown Game", client.Name); } + /// + /// A GeneralsOnline directory holds both the Easy Anti-Cheat bootstrapper and the binary it + /// wraps. A scan must report the installation once, through the bootstrapper. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithEacLauncherBesideSixtyHertz_FindsOnlyWrapper() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + var sixtyHertzPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.genhub.gameclient.unknownclient") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await _detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(wrapperPath, only.ExecutablePath); + } + + /// + /// The bootstrapper is absent from the retail hash registry by definition, so an unrecognized + /// hash must fall through to the publisher identifier rather than to the generic entry. A + /// GeneralsOnline client reported as GameType.Generals never matches the Zero Hour launch + /// path, which is what writes settings.json. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithEacLauncherAndUnknownHash_ClassifiesAsZeroHourGeneralsOnline() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.generalsonline.gameclient.generals-generalsonline-60hz") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // The production identifier, so this pins real classification rather than a mock's answer. + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [new GeneralsOnlineClientIdentifier()], + NullLogger.Instance); + + var result = await detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(wrapperPath, only.ExecutablePath); + Assert.Equal(GameType.ZeroHour, only.GameType); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, only.Name); + + // IsPublisherClient turns on PublisherType alone. Without it the client reads as a base + // retail install, so version resolution treats it as the base game and the launcher UI + // never sees a publisher client. + Assert.Equal(PublisherTypeConstants.GeneralsOnline, only.PublisherType); + Assert.True(only.IsPublisherClient); + } + + /// + /// One misbehaving identifier must not take the rest down with it. The caller's handler + /// swallows anything thrown here and returns null, so an escaping exception would drop the + /// executable entirely rather than falling through to the identifiers after it. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WhenAnIdentifierThrows_StillTriesTheRest() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.generalsonline.gameclient.generals-generalsonline-60hz") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Throws from CanIdentify, which is the probe that runs before Identify. + var throwingIdentifier = new Mock(); + throwingIdentifier.Setup(x => x.PublisherId).Returns("throwing"); + throwingIdentifier.Setup(x => x.CanIdentify(It.IsAny())).Throws(new InvalidOperationException("boom")); + + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [throwingIdentifier.Object, new GeneralsOnlineClientIdentifier()], + NullLogger.Instance); + + var result = await detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(GameType.ZeroHour, only.GameType); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, only.Name); + } + + /// + /// Portables predating 060526_QFE1 ship no bootstrapper, so the wrapped binary stays the + /// entry point rather than being filtered out with it. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithoutEacLauncher_FindsSixtyHertzClient() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnlinePreEac"); + Directory.CreateDirectory(gameDir); + var sixtyHertzPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.genhub.gameclient.unknownclient") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await _detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(sixtyHertzPath, only.ExecutablePath); + } + /// /// Tests that DetectGameClientsFromInstallationsAsync detects GeneralsOnline 60Hz client. /// @@ -403,6 +582,59 @@ public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60Hz Assert.Contains("60Hz", generalsOnlineClient.Name); } + /// + /// Since 060526_QFE1 the Easy Anti-Cheat bootstrapper ships beside the binary it wraps. + /// Detection must yield a single client pointing at the bootstrapper, not one client per + /// recognised executable name. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DetectGameClientsFromInstallationsAsync_WithEacLauncherBesideSixtyHertz_DetectsOnlyWrapper() + { + var identifierMock = new Mock(); + identifierMock.Setup(x => x.PublisherId).Returns(PublisherTypeConstants.GeneralsOnline); + identifierMock.Setup(x => x.CanIdentify(It.IsAny())).Returns(false); + + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [identifierMock.Object], + NullLogger.Instance); + + var zeroHourPath = Path.Combine(_tempDirectory, "ZeroHourEac"); + Directory.CreateDirectory(zeroHourPath); + + var wrapperPath = Path.Combine(zeroHourPath, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + var sixtyHertzPath = Path.Combine(zeroHourPath, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + var installation = new GameInstallation("C:\\TestInstallEac", GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zeroHourPath, + }; + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("any_hash"); + + _contentManifestPoolMock + .Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await detector.DetectGameClientsFromInstallationsAsync([installation]); + + Assert.True(result.Success); + var generalsOnlineClients = result.Items + .Where(client => client.Name.Contains("GeneralsOnline", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var only = Assert.Single(generalsOnlineClients); + Assert.Equal(wrapperPath, only.ExecutablePath); + } + /// /// Tests that DetectGameClientsFromInstallationsAsync detects GeneralsOnline 60Hz client for Zero Hour. /// 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 cd6e2f30a..74bb0ac7c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -22,6 +22,210 @@ public GameProcessManagerTests() _processManager = new GameProcessManager(_loggerMock.Object); } + /// + /// A process that was just started successfully is running, and the returned information has + /// to say so — consumers read to decide launch state. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithLiveProcess_ReportsItAsRunning() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.True(result.Data!.IsRunning); + + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + + /// + /// Launch state is re-read through after + /// the launch returns, so that path has to report running state too — not just the one that + /// started the process. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetProcessInfoAsync_ForALiveProcess_ReportsItAsRunning() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var started = await _processManager.StartProcessAsync(config); + Assert.True(started.Success, string.Join(", ", started.Errors)); + + var info = await _processManager.GetProcessInfoAsync(started.Data!.ProcessId); + + Assert.True(info.Success, string.Join(", ", info.Errors)); + Assert.True(info.Data!.IsRunning); + + await _processManager.TerminateProcessAsync(started.Data.ProcessId); + } + + /// + /// The Easy Anti-Cheat bootstrapper spawns the game and then keeps running for about a minute. + /// Tracking must follow the spawned child and must not wait for the launcher to exit first. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithExpectedChild_TracksTheChildWhileTheLauncherStillRuns() + { + if (!OperatingSystem.IsWindows()) + { + // Process.GetProcessesByName does not enumerate these processes on macOS, so adoption + // cannot be observed there. The behaviour is Windows-only in practice. + return; + } + + using var harness = LauncherHarness.Create(); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await _processManager.StartProcessAsync(config); + stopwatch.Stop(); + + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.NotNull(result.Data); + Assert.Equal(LauncherHarness.ChildProcessName, result.Data!.ProcessName); + + // The launcher outlives this call by design; returning quickly proves tracking did not + // wait for it to exit, which is what made the real bootstrapper untrackable. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(LauncherHarness.LauncherLifetimeSeconds / 2.0), + $"tracking took {stopwatch.Elapsed}, so it waited for the launcher"); + + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + + /// + /// When a child is expected but never appears, the launch fails rather than silently falling + /// back to tracking the launcher — which would report the game as running when it is not. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithExpectedChildThatNeverAppears_FailsInsteadOfTrackingTheLauncher() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromMilliseconds(750), + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.False(result.Success); + Assert.Contains(LauncherHarness.ChildProcessName, string.Join(", ", result.Errors)); + } + + /// + /// A bootstrapper that bails without launching the game exits with code 0, so the exit code + /// alone cannot distinguish it from success. Once the launcher is gone no child is coming, and + /// waiting out the full discovery timeout only delays the failure behind a misleading message. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_FailsWithoutWaitingOutTheTimeout() + { + using var harness = LauncherHarness.Create(spawnChild: false, exitImmediately: true, stderrMessage: null); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(10), + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await _processManager.StartProcessAsync(config); + stopwatch.Stop(); + + Assert.False(result.Success); + Assert.Contains("without starting", string.Join(", ", result.Errors)); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"Expected a fast failure once the launcher exited, but it took {stopwatch.Elapsed}."); + } + + /// + /// The clean-exit failure and the stderr diagnostics are complementary and belong together: + /// this path fires only once the launcher has provably exited, which is exactly the condition + /// AppendLauncherErrors requires before draining is safe. So the message that says the game + /// never started can also carry the bootstrapper's own explanation of why. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_ReportsItsStderr() + { + const string complaint = "EasyAntiCheat_is_not_installed"; + using var harness = LauncherHarness.Create(spawnChild: false, exitImmediately: true, stderrMessage: complaint); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(10), + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.False(result.Success); + + var errors = string.Join(", ", result.Errors); + Assert.Contains("without starting", errors); + Assert.Contains(complaint, errors); + } + + /// + /// A cancelled adoption must surface as cancellation rather than a generic start failure. + /// Swallowing it disagrees with TerminateProcessAsync, which rethrows, and prevents + /// GameLauncher.LaunchProfileAsync from reaching its own cancellation branch. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenAdoptionIsCancelled_PropagatesCancellation() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + + // Long enough that the timeout cannot be what ends the wait. + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(30), + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + await Assert.ThrowsAnyAsync( + () => _processManager.StartProcessAsync(config, cts.Token)); + } + /// /// Tests that StartProcessAsync handles invalid executable path. /// @@ -148,4 +352,190 @@ public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccess() File.Delete(tempExe); } } + + /// + /// A disposable stand-in for the Easy Anti-Cheat bootstrapper: a launcher that outlives the + /// call which starts it, optionally spawning a distinctly named child inside the working + /// directory. Uses copies of real long-running system binaries so the child has a process name + /// of its own, which is what selection keys on. + /// + private sealed class LauncherHarness : IDisposable + { + /// The process name the spawned child reports. + public const string ChildProcessName = "genhubchild"; + + /// How long the launcher keeps running after it spawns the child. + public const int LauncherLifetimeSeconds = 20; + + /// File the launcher writes its own PID into, so Dispose can stop it. + private const string LauncherPidFileName = "launcher.pid"; + + private LauncherHarness(string workingDirectory, string launcherPath) + { + WorkingDirectory = workingDirectory; + LauncherPath = launcherPath; + } + + /// Gets the directory the launcher and child run from. + public string WorkingDirectory { get; } + + /// Gets the path of the launcher to start. + public string LauncherPath { get; } + + /// Creates a harness, optionally spawning a child. + /// Whether the launcher should spawn the child. + /// Whether the launcher should exit cleanly instead of staying alive. + /// A line the launcher writes to stderr before doing anything else. + /// The created harness. + public static LauncherHarness Create(bool spawnChild = true, bool exitImmediately = false, string? stderrMessage = null) + { + var workingDirectory = Path.Combine(Path.GetTempPath(), "genhub-launcher-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + + var childPath = Path.Combine(workingDirectory, OperatingSystem.IsWindows() ? ChildProcessName + ".exe" : ChildProcessName); + File.Copy(LongRunningSystemBinary(), childPath); + + string launcherPath; + string script; + if (OperatingSystem.IsWindows()) + { + launcherPath = Path.Combine(workingDirectory, "genhublauncher.bat"); + var spawn = spawnChild ? $"start \"\" /b \"{childPath}\" -n {LauncherLifetimeSeconds + 1} 127.0.0.1 >nul\n" : string.Empty; + + // 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"; + + // 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. + var linger = exitImmediately ? string.Empty : $"ping -n {LauncherLifetimeSeconds + 1} 127.0.0.1 >nul\n"; + var complain = stderrMessage is null ? string.Empty : $"echo {stderrMessage} 1>&2\n"; + script = $"@echo off\n{recordPid}{complain}{spawn}cd /d \"%TEMP%\"\n{linger}"; + } + else + { + launcherPath = Path.Combine(workingDirectory, "genhublauncher.sh"); + var spawn = spawnChild ? $"\"{childPath}\" {LauncherLifetimeSeconds} &\n" : string.Empty; + var linger = exitImmediately ? string.Empty : $"sleep {LauncherLifetimeSeconds}\n"; + var complain = stderrMessage is null ? string.Empty : $"echo \"{stderrMessage}\" >&2\n"; + + // The harness does not start the launcher, so the launcher reports its own PID. + script = $"#!/bin/bash\necho $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n{complain}{spawn}{linger}"; + } + + File.WriteAllText(launcherPath, script); + MakeExecutable(launcherPath); + MakeExecutable(childPath); + + return new LauncherHarness(workingDirectory, launcherPath); + } + + /// + public void Dispose() + { + KillLauncher(); + + foreach (var process in System.Diagnostics.Process.GetProcessesByName(ChildProcessName)) + { + try + { + if (GetImagePath(process)?.StartsWith(WorkingDirectory, StringComparison.OrdinalIgnoreCase) == true) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(2000); + } + } + catch + { + // Best effort - the process may already be gone. + } + finally + { + process.Dispose(); + } + } + + DeleteWorkingDirectory(); + } + + /// + /// Stops the launcher so it does not outlive the test by . + /// + private void KillLauncher() + { + var pidFile = Path.Combine(WorkingDirectory, LauncherPidFileName); + + try + { + if (!File.Exists(pidFile) || !int.TryParse(File.ReadAllText(pidFile).Trim(), out var launcherId)) + { + return; + } + + using var launcher = System.Diagnostics.Process.GetProcessById(launcherId); + launcher.Kill(entireProcessTree: true); + launcher.WaitForExit(2000); + } + catch + { + // Best effort - the launcher may have exited, or never recorded a PID. + } + } + + private void DeleteWorkingDirectory() + { + // A killed process can hold a handle for a moment after it stops, so retry rather than + // leaking the directory for the rest of the run. + for (var attempt = 0; attempt < 5; attempt++) + { + try + { + Directory.Delete(WorkingDirectory, recursive: true); + return; + } + catch when (attempt < 4) + { + Thread.Sleep(100); + } + catch + { + // Best effort. + } + } + } + + private static string? GetImagePath(System.Diagnostics.Process process) + { + try + { + return process.MainModule?.FileName; + } + catch + { + return null; + } + } + + private static string LongRunningSystemBinary() + { + if (OperatingSystem.IsWindows()) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "PING.EXE"); + } + + return File.Exists("/bin/sleep") ? "/bin/sleep" : "/usr/bin/sleep"; + } + + private static void MakeExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var chmod = System.Diagnostics.Process.Start("chmod", ["+x", path]); + chmod?.WaitForExit(); + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs new file mode 100644 index 000000000..e39b4e76e --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs @@ -0,0 +1,161 @@ +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Models.Launching; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public class GameProcessSelectorTests +{ + private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); + + // Native separators on both platforms: a real workspace path never mixes them, and comparing + // like-for-like is what the non-separator tests are meant to exercise. + private static readonly string Workspace = Path.Combine(Path.GetTempPath(), "genhub-workspace", "generalsonline"); + + /// + /// The spawned game is identified by the name the caller expects, not by the launcher's name. + /// + [Fact] + public void SelectSpawnedGameProcess_MatchesTheExpectedNameCaseInsensitively() + { + var candidates = new[] + { + Candidate(1, "EAC_LaunchGeneralsOnline", Now.AddSeconds(-2), Workspace), + Candidate(2, "GENERALSONLINEZH_60", Now.AddSeconds(-1), Workspace), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "generalsonlinezh_60", Workspace, Now); + + Assert.NotNull(selected); + Assert.Equal(2, selected.ProcessId); + } + + /// + /// A same-named process that predates the launch is somebody else's, not the child we spawned. + /// + [Fact] + public void SelectSpawnedGameProcess_RejectsCandidatesStartedBeforeTheRecencyWindow() + { + var stale = Now.AddSeconds(-(ProcessConstants.EarlyExitThresholdSeconds + 1)); + var candidates = new[] { Candidate(1, "GeneralsOnlineZH_60", stale, Workspace) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", Workspace, Now); + + Assert.Null(selected); + } + + /// + /// Workspace residence must be required even when only one candidate matches the name — a lone + /// same-named process anywhere on the machine used to be accepted unconditionally. + /// + [Fact] + public void SelectSpawnedGameProcess_RejectsALoneCandidateOutsideTheWorkingDirectory() + { + var candidates = new[] { Candidate(1, "GeneralsOnlineZH_60", Now, "/somewhere/else") }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", Workspace, Now); + + Assert.Null(selected); + } + + /// + /// Residence cannot be proven for a process whose image path is unreadable, so it is not + /// accepted while a working directory is being enforced. + /// + [Fact] + public void SelectSpawnedGameProcess_RejectsCandidatesWithAnUnknownExecutablePath() + { + var candidates = new[] { new GameProcessCandidate(1, "GeneralsOnlineZH_60", Now, null) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", Workspace, Now); + + Assert.Null(selected); + } + + /// + /// With no working directory to enforce, name and recency are the only available evidence. + /// + [Fact] + public void SelectSpawnedGameProcess_WithoutAWorkingDirectory_AcceptsOnNameAndRecency() + { + var candidates = new[] { new GameProcessCandidate(1, "GeneralsOnlineZH_60", Now, null) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", null, Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// When several qualify, the newest is the one this launch just spawned. + /// + [Fact] + public void SelectSpawnedGameProcess_PrefersTheMostRecentlyStartedCandidate() + { + var candidates = new[] + { + Candidate(1, "GeneralsOnlineZH_60", Now.AddSeconds(-5), Workspace), + Candidate(2, "GeneralsOnlineZH_60", Now.AddSeconds(-1), Workspace), + Candidate(3, "GeneralsOnlineZH_60", Now.AddSeconds(-3), Workspace), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", Workspace, Now); + + Assert.NotNull(selected); + Assert.Equal(2, selected.ProcessId); + } + + /// + /// A trailing separator on the working directory is a formatting difference, not a mismatch. + /// + [Fact] + public void SelectSpawnedGameProcess_IgnoresTrailingSeparatorsOnTheWorkingDirectory() + { + var candidates = new[] { Candidate(1, "GeneralsOnlineZH_60", Now, Workspace) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, "GeneralsOnlineZH_60", Workspace + Path.DirectorySeparatorChar, Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// Separator style is a spelling difference, not a location difference. Windows accepts both + /// forms, so a working directory and a process image path can legitimately disagree on which + /// one they use and still name the same directory. Only discriminating on Windows: elsewhere + /// both separator constants are '/', and a backslash is a legal file name character that must + /// not be treated as a separator. + /// + [Fact] + public void SelectSpawnedGameProcess_IgnoresSeparatorStyleWhenComparingResidence() + { + var candidates = new[] { Candidate(1, "GeneralsOnlineZH_60", Now, Workspace) }; + var alternateSpelling = Workspace.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, "GeneralsOnlineZH_60", alternateSpelling, Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// Nothing matching the expected name means no adoption. + /// + [Fact] + public void SelectSpawnedGameProcess_WithNoNameMatch_ReturnsNull() + { + var candidates = new[] { Candidate(1, "EAC_LaunchGeneralsOnline", Now, Workspace) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, "GeneralsOnlineZH_60", Workspace, Now); + + Assert.Null(selected); + } + + private static GameProcessCandidate Candidate(int id, string name, DateTime startTime, string directory) => + new(id, name, startTime, Path.Combine(directory, name + ".exe")); +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/LaunchEntryPointResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/LaunchEntryPointResolverTests.cs new file mode 100644 index 000000000..6abee48c5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/LaunchEntryPointResolverTests.cs @@ -0,0 +1,74 @@ +using GenHub.Core.Constants; +using GenHub.Core.Helpers; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public class LaunchEntryPointResolverTests +{ + /// + /// The Easy Anti-Cheat bootstrapper starts the 60Hz client and keeps running, so tracking has + /// to be told which process the session actually moves to. + /// + [Fact] + public void ResolveExpectedChildProcessName_ForTheAntiCheatBootstrapper_ReturnsTheSixtyHertzClient() + { + var path = Path.Combine("/workspace", GameClientConstants.GeneralsOnlineEacLauncherExecutable); + + var child = LaunchEntryPointResolver.ResolveExpectedChildProcessName(path); + + Assert.Equal( + Path.GetFileNameWithoutExtension(GameClientConstants.GeneralsOnline60HzExecutable), + child); + } + + /// + /// The bootstrapper ships with mixed-case naming; matching must not depend on it. + /// + [Fact] + public void ResolveExpectedChildProcessName_MatchesTheBootstrapperCaseInsensitively() + { + var path = Path.Combine("/workspace", GameClientConstants.GeneralsOnlineEacLauncherExecutable.ToUpperInvariant()); + + var child = LaunchEntryPointResolver.ResolveExpectedChildProcessName(path); + + Assert.NotNull(child); + } + + /// + /// A pre-EAC portable launches the game directly — there is no child to wait for, and claiming + /// one would make every legacy launch fail. + /// + [Fact] + public void ResolveExpectedChildProcessName_ForTheSixtyHertzClientItself_ReturnsNull() + { + var path = Path.Combine("/workspace", GameClientConstants.GeneralsOnline60HzExecutable); + + Assert.Null(LaunchEntryPointResolver.ResolveExpectedChildProcessName(path)); + } + + /// + /// Every other client launches its own executable and is unaffected. + /// + [Fact] + public void ResolveExpectedChildProcessName_ForAnOrdinaryExecutable_ReturnsNull() + { + var path = Path.Combine("/workspace", GameClientConstants.GeneralsExecutable); + + Assert.Null(LaunchEntryPointResolver.ResolveExpectedChildProcessName(path)); + } + + /// + /// A missing path resolves to no expectation rather than throwing. + /// + /// The path under test. + [Theory] + [InlineData("")] + [InlineData(null)] + public void ResolveExpectedChildProcessName_WithoutAPath_ReturnsNull(string? path) + { + Assert.Null(LaunchEntryPointResolver.ResolveExpectedChildProcessName(path)); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifier.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifier.cs index f61bed278..bb82be88d 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifier.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifier.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameClients; using GenHub.Core.Models.Enums; @@ -16,18 +17,12 @@ public class GeneralsOnlineClientIdentifier : IGameClientIdentifier public string PublisherId => PublisherTypeConstants.GeneralsOnline; /// - public bool CanIdentify(string executablePath) - { - var fileName = Path.GetFileName(executablePath); - return fileName.Equals(GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase); - } + public bool CanIdentify(string executablePath) => IsSupportedEntryPoint(Path.GetFileName(executablePath)); /// public GameClientIdentification? Identify(string executablePath) { - var fileName = Path.GetFileName(executablePath); - - if (!fileName.Equals(GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase)) + if (!IsSupportedEntryPoint(Path.GetFileName(executablePath))) { return null; } @@ -39,4 +34,13 @@ public bool CanIdentify(string executablePath) gameType: GameType.ZeroHour, localVersion: null); // Don't fetch from web during detection! } + + /// + /// Determines whether a file name is a supported Generals Online entry point. Since + /// 060526_QFE1 that is the Easy Anti-Cheat bootstrapper; older packages launch the 60Hz + /// binary directly. GeneralsOnlineZH.exe ships alongside both but is not wrapped by + /// Easy Anti-Cheat, so it is workspace content rather than an entry point. + /// + private static bool IsSupportedEntryPoint(string fileName) => + GameClientConstants.GeneralsOnlineExecutableNames.Contains(fileName, StringComparer.OrdinalIgnoreCase); } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index dc14e74a7..b45d74e7c 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -197,6 +197,16 @@ public async Task> CreateManifestsFromLocalInstallAsync( private static int ParseVersionForManifestId(string version) => GameVersionHelper.GetGeneralsOnlineManifestIdComponent(version); + /// + /// Determines whether a manifest-relative path is the named file at the archive root. + /// Nested files that merely share the name are not the published entry point. + /// + private static bool IsArchiveRootFile(string relativePath, string fileName) => + string.Equals( + relativePath.Replace('\\', '/').TrimStart('/'), + fileName, + StringComparison.OrdinalIgnoreCase); + private static ManifestFile CreateMapManifestFile(string relativePath, FileInfo fileInfo, string hash) { // For maps, the relative path should be relative to the Maps directory @@ -464,11 +474,18 @@ private async Task> UpdateManifestsWithExtractedFiles( { // Game client manifest: include executables, shared files, AND map files // Map files are included with UserMapsDirectory install target so they install to Documents - var targetExecutable = GameClientConstants.GeneralsOnline60HzExecutable; + // Since 060526_QFE1 the portable ships an Easy Anti-Cheat bootstrapper that starts the + // binary named by EasyAntiCheat/Settings.json. When present it is the only launch target; + // the wrapped binary stays in the workspace as ordinary content for EAC to start. + var hasEacLauncher = filesWithHashes.Any(file => + !file.IsMap && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); + + var targetExecutable = hasEacLauncher + ? GameClientConstants.GeneralsOnlineEacLauncherExecutable + : GameClientConstants.GeneralsOnline60HzExecutable; foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) { - var fileName = Path.GetFileName(relativePath); var isExecutable = false; // Skip map files in GameClient manifests - they belong in the MapPack manifest @@ -477,14 +494,10 @@ private async Task> UpdateManifestsWithExtractedFiles( continue; } - if (string.Equals(fileName, targetExecutable, StringComparison.OrdinalIgnoreCase)) + if (IsArchiveRootFile(relativePath, targetExecutable)) { isExecutable = true; } - else if (string.Equals(fileName, GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase)) - { - continue; - } manifestFiles.Add(new ManifestFile { diff --git a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs index 2004fe320..43af101dd 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientDetector.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientDetector.cs @@ -170,6 +170,53 @@ public Task ValidateGameClientAsync( return Task.FromResult(isValid); } + /// + /// Resolves the single supported Generals Online entry point among one directory's file names. + /// The Easy Anti-Cheat bootstrapper takes precedence because it starts the binary named by + /// EasyAntiCheat/Settings.json; the bare 60Hz binary is the pre-EAC fallback. + /// + /// The file names present in a single directory. + /// The entry point name as it appears on disk, or when none is present. + private static string? ResolveGeneralsOnlineEntryPoint(IEnumerable fileNames) + { + string? sixtyHertz = null; + + foreach (var fileName in fileNames) + { + if (fileName.Equals(GameClientConstants.GeneralsOnlineEacLauncherExecutable, StringComparison.OrdinalIgnoreCase)) + { + return fileName; + } + + if (fileName.Equals(GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase)) + { + sixtyHertz = fileName; + } + } + + return sixtyHertz; + } + + /// + /// Resolves the single supported Generals Online entry point in a directory. Names are matched + /// against the directory listing rather than composed from constants, so the package's own + /// casing resolves on case-sensitive file systems. + /// + /// The directory to inspect. + /// The entry point name, or when none is present. + private static string? ResolveGeneralsOnlineEntryPoint(string directory) + { + try + { + return ResolveGeneralsOnlineEntryPoint( + Directory.EnumerateFiles(directory).Select(Path.GetFileName).OfType()); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + /// /// Detects a game client from a specific executable file using hash analysis. /// @@ -224,6 +271,17 @@ public Task ValidateGameClientAsync( }; } + // A publisher entry point is absent from the retail hash registry by definition, so an + // unrecognized hash means "not retail" rather than "unidentifiable". Ask the publisher + // identifiers before falling back, otherwise the GeneralsOnline anti-cheat bootstrapper + // is reported as Unknown Game with GameType.Generals and never matches the Zero Hour + // launch path. + var identifiedClient = IdentifyPublisherClient(executablePath, workingDirectory); + if (identifiedClient != null) + { + return identifiedClient; + } + // If hash is not recognized, create a generic entry for manual identification logger.LogDebug("Unknown game executable found at {ExecutablePath} with hash {Hash}", executablePath, hash); return new GameClient @@ -245,6 +303,67 @@ public Task ValidateGameClientAsync( } } + /// + /// Classifies an executable through the registered publisher identifiers. + /// + /// The path to the executable file. + /// The working directory for the game client. + /// A GameClient if a publisher recognizes the executable, otherwise null. + private GameClient? IdentifyPublisherClient(string executablePath, string workingDirectory) + { + foreach (var identifier in gameClientIdentifiers) + { + try + { + // Inside the try: a throwing identifier must not stop the ones after it, and + // the caller's handler would swallow the executable entirely. + if (!identifier.CanIdentify(executablePath)) + { + continue; + } + + var identification = identifier.Identify(executablePath); + if (identification == null) + { + continue; + } + + logger.LogInformation( + "Identified {PublisherId} client {DisplayName} at {ExecutablePath}", + identification.PublisherId, + identification.DisplayName, + executablePath); + + return new GameClient + { + Name = identification.DisplayName, + Id = string.Empty, // Will be set by manifest generation + Version = identification.LocalVersion ?? GameClientConstants.UnknownVersion, + ExecutablePath = executablePath, + GameType = identification.GameType, + WorkingDirectory = workingDirectory, + InstallationId = string.Empty, + SourceType = ContentType.GameClient, + + // IsPublisherClient turns on this alone. Without it the client reads as a + // base retail install, so version resolution picks it as the base game and + // the launcher UI does not see a publisher client at all. + PublisherType = identification.PublisherId, + }; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Publisher identifier {PublisherId} failed for {ExecutablePath}", + identifier.PublisherId, + executablePath); + } + } + + return null; + } + private async Task GenerateClientManifestAndSetIdAsync(GameClient gameClient, string clientPath, GameInstallation? installation, GameType gameType) { try @@ -623,7 +742,6 @@ private Task DetectPublisherClientsFromLocalFilesAsync( /// /// The game installation to scan. /// The type of game (Generals or ZeroHour). - /// A list of detected GeneralsOnline game clients. /// /// GeneralsOnline executables are auto-updated by the GeneralsOnline launcher, @@ -645,34 +763,20 @@ private Task> DetectGeneralsOnlineClientsAsync( // GeneralsOnline clients auto-update, so we use a fixed version string const string generalsOnlineVersion = GameClientConstants.UnknownVersion; - var generalsOnlineExecutables = GameClientConstants.GeneralsOnlineExecutableNames; + // Exactly one entry point per installation. Since 060526_QFE1 the Easy Anti-Cheat + // bootstrapper wraps the 60Hz binary and both ship side by side, so detecting each + // recognised name in turn would surface the same client twice. + var executableName = ResolveGeneralsOnlineEntryPoint(installationPath); - foreach (var executableName in generalsOnlineExecutables) + if (executableName is not null) { var executablePath = Path.Combine(installationPath, executableName); - if (!File.Exists(executablePath)) - { - continue; - } - try { - // Determine the variant name from the executable - var variantName = executableName switch - { - GameClientConstants.GeneralsOnline60HzExecutable => GameClientConstants.GeneralsOnline60HzDisplayName, - _ => null, // Skip unknown variants - }; - - // Skip if variant is not recognized - if (variantName == null) - { - logger.LogDebug( - "Skipping unrecognized GeneralsOnline executable: {ExecutableName}", - executableName); - continue; - } + // Both supported entry points start the 60Hz client: the bootstrapper launches + // the binary named by EasyAntiCheat/Settings.json, and pre-EAC packages run it directly. + var variantName = GameClientConstants.GeneralsOnline60HzDisplayName; logger.LogInformation( "Detected GeneralsOnline client: {VariantName} at {ExecutablePath}", @@ -880,12 +984,27 @@ private List FindGameExecutablesRecursively(string rootPath) try { // Process files in current directory - foreach (var file in Directory.EnumerateFiles(currentDir)) + var files = Directory.EnumerateFiles(currentDir).ToList(); + var generalsOnlineEntryPoint = ResolveGeneralsOnlineEntryPoint( + files.Select(Path.GetFileName).OfType()); + + foreach (var file in files) { - if (hashRegistry.PossibleExecutableNames.Contains(Path.GetFileName(file), StringComparer.OrdinalIgnoreCase)) + var fileName = Path.GetFileName(file); + if (!hashRegistry.PossibleExecutableNames.Contains(fileName, StringComparer.OrdinalIgnoreCase)) { - results.Add(file); + continue; } + + // A GeneralsOnline directory holds several supported entry points but is one + // client, so only the resolved entry point counts. + if (GameClientConstants.GeneralsOnlineExecutableNames.Contains(fileName, StringComparer.OrdinalIgnoreCase) + && !fileName.Equals(generalsOnlineEntryPoint, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + results.Add(file); } // Enqueue subdirectories if not excluded diff --git a/GenHub/GenHub/Features/GameClients/GameClientHashRegistry.cs b/GenHub/GenHub/Features/GameClients/GameClientHashRegistry.cs index 69bc0e62e..5f0bc94b5 100644 --- a/GenHub/GenHub/Features/GameClients/GameClientHashRegistry.cs +++ b/GenHub/GenHub/Features/GameClients/GameClientHashRegistry.cs @@ -56,6 +56,7 @@ public GameClientHashRegistry() // Publisher clients GameClientConstants.SuperHackersGeneralsExecutable, GameClientConstants.SuperHackersZeroHourExecutable, + GameClientConstants.GeneralsOnlineEacLauncherExecutable, GameClientConstants.GeneralsOnline60HzExecutable, ]; diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 673e1edc7..d757c2d4c 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Models.Events; @@ -220,6 +221,15 @@ public async Task> StartProcessAsync(GameLaunch logger.LogDebug(ex, "[Process] Could not capture stderr for process {ProcessId}", process.Id); } + // A launcher that hands the session to another binary is tracked through that binary. + // Poll for it independently of the launcher's own lifetime: the Easy Anti-Cheat + // bootstrapper outlives the game's startup by about a minute, so waiting for it to + // exit first never finds the game. + if (!string.IsNullOrWhiteSpace(configuration.ExpectedChildProcessName)) + { + return await AdoptExpectedChildProcessAsync(process, configuration, workingDirectory, capturedErrors, cancellationToken); + } + // Check if process exited immediately (launcher pattern) // Only apply delay if we need to detect a spawned process if (!isBatchFile) @@ -248,7 +258,13 @@ public async Task> StartProcessAsync(GameLaunch "[Process] Launcher process {ProcessId} exited with code 0 - attempting to find spawned game process", process.Id); - var executableName = Path.GetFileNameWithoutExtension(configuration.ExecutablePath); + // A bootstrapper hands the session to a differently-named binary, so the + // name to adopt comes from the caller — never from the path we started. + // Same emptiness test as the adoption guard above: `??` would accept a + // whitespace-only name that skipped adoption, then match no process. + var executableName = !string.IsNullOrWhiteSpace(configuration.ExpectedChildProcessName) + ? configuration.ExpectedChildProcessName + : Path.GetFileNameWithoutExtension(configuration.ExecutablePath); var spawnedProcess = FindSpawnedGameProcess(executableName, configuration.WorkingDirectory ?? Path.GetDirectoryName(configuration.ExecutablePath)!); if (spawnedProcess != null) @@ -273,28 +289,7 @@ public async Task> StartProcessAsync(GameLaunch logger.LogWarning(ex, "Failed to enable raising events for spawned process {ProcessId}", spawnedProcess.Id); } - GameProcessInfo spawnedProcessInfo; - try - { - spawnedProcessInfo = new GameProcessInfo - { - ProcessId = spawnedProcess.Id, - ProcessName = spawnedProcess.ProcessName, - StartTime = spawnedProcess.StartTime, - ExecutablePath = GetProcessExecutablePath(spawnedProcess), - }; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get process information for {ProcessId}, using minimal info", spawnedProcess.Id); - spawnedProcessInfo = new GameProcessInfo - { - ProcessId = spawnedProcess.Id, - ProcessName = GameClientConstants.UnknownVersion, - StartTime = DateTime.Now, - ExecutablePath = configuration.ExecutablePath, - }; - } + var spawnedProcessInfo = BuildProcessInfo(spawnedProcess, configuration.ExecutablePath); logger.LogInformation("Started game process {ProcessId} for executable {ExecutablePath}", spawnedProcess.Id, configuration.ExecutablePath); return OperationResult.CreateSuccess(spawnedProcessInfo); @@ -371,32 +366,18 @@ public async Task> StartProcessAsync(GameLaunch logger.LogWarning(ex, "Failed to enable raising events for process {ProcessId}, process cleanup may not work properly", process.Id); } - GameProcessInfo processInfo; - try - { - processInfo = new GameProcessInfo - { - ProcessId = process.Id, - ProcessName = process.ProcessName, - StartTime = process.StartTime, - ExecutablePath = GetProcessExecutablePath(process), - }; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to get process information for {ProcessId}, using minimal info", process.Id); - processInfo = new GameProcessInfo - { - ProcessId = process.Id, - ProcessName = GameClientConstants.UnknownVersion, - StartTime = DateTime.Now, - ExecutablePath = configuration.ExecutablePath, - }; - } + var processInfo = BuildProcessInfo(process, configuration.ExecutablePath); logger.LogInformation("Started game process {ProcessId} for executable {ExecutablePath}", process.Id, configuration.ExecutablePath); return OperationResult.CreateSuccess(processInfo); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // A cancelled launch is not a start failure. Reporting it as one hides the reason from + // the caller and bypasses GameLauncher.LaunchProfileAsync's cancellation handling. + logger.LogInformation("Start of {ExecutablePath} was cancelled", configuration.ExecutablePath); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to start process for executable {ExecutablePath}", configuration.ExecutablePath); @@ -512,6 +493,7 @@ public Task> GetProcessInfoAsync(int processId, ProcessName = process.ProcessName, StartTime = process.StartTime, ExecutablePath = GetProcessExecutablePath(process), + IsRunning = IsStillRunning(process), }; return Task.FromResult(OperationResult.CreateSuccess(processInfo)); @@ -532,6 +514,7 @@ public Task> GetProcessInfoAsync(int processId, ProcessName = process.ProcessName, StartTime = process.StartTime, ExecutablePath = GetProcessExecutablePath(process), + IsRunning = IsStillRunning(process), }; return Task.FromResult(OperationResult.CreateSuccess(processInfo)); @@ -568,6 +551,7 @@ public Task>> GetActiveProcessesA ProcessName = process.ProcessName, StartTime = process.StartTime, ExecutablePath = GetProcessExecutablePath(process), + IsRunning = IsStillRunning(process), }; activeProcesses.Add(processInfo); } @@ -654,13 +638,14 @@ public async Task> DiscoverAndTrackProcessAsync logger.LogWarning(ex, "Failed to enable raising events for discovered process {ProcessId}", process.Id); } - return OperationResult.CreateSuccess(new GameProcessInfo - { - ProcessId = process.Id, - ProcessName = process.ProcessName, - StartTime = process.StartTime, - ExecutablePath = GetProcessExecutablePath(process), - }); + // BuildProcessInfo assigns the fallback to GameProcessInfo.ExecutablePath, which + // GameLauncher persists. Passing the directory alone would store a folder where a + // file path is expected, so rebuild the executable path from what we were given. + var fallbackExecutable = Path.Combine( + workingDirectory, + OperatingSystem.IsWindows() ? processName + ".exe" : processName); + + return OperationResult.CreateSuccess(BuildProcessInfo(process, fallbackExecutable)); } await Task.Delay(DelayMs, cancellationToken); @@ -747,6 +732,23 @@ public void Dispose() logger.LogInformation("GameProcessManager disposed"); } + /// + /// Reports whether a process is still running, treating an unreadable process as not running. + /// + /// The process to check. + /// when the process is known to be running. + private static bool IsStillRunning(Process process) + { + try + { + return !process.HasExited; + } + catch + { + return false; + } + } + private static string GetProcessExecutablePath(Process process) { try @@ -824,77 +826,365 @@ private void OnProcessExited(object? sender, EventArgs e) } /// - /// Finds a spawned game process by executable name and working directory. - /// Used when a launcher executable spawns the actual game and exits. + /// Waits for a launcher to spawn the process named by + /// and tracks that process + /// instead of the launcher. The launcher's own exit is never treated as the game exiting. /// - /// The base executable name without extension. - /// The expected working directory. - /// The spawned process if found, null otherwise. - private Process? FindSpawnedGameProcess(string executableName, string workingDirectory) + /// The process that was started. + /// The launch configuration. + /// The directory the game must run from. + /// + /// The launcher's captured stderr, quoted in the failure messages so a bootstrapper + /// that refuses to start the game can say why. + /// + /// Cancellation token. + /// The adopted child process, or a failure describing why none was adopted. + private async Task> AdoptExpectedChildProcessAsync( + Process launcher, + GameLaunchConfiguration configuration, + string workingDirectory, + BoundedErrorBuffer capturedErrors, + CancellationToken cancellationToken) { + var expectedName = configuration.ExpectedChildProcessName!; + var timeout = configuration.ExpectedChildDiscoveryTimeout + ?? TimeSpan.FromMilliseconds(ProcessConstants.SpawnedChildDiscoveryTimeoutMs); + var deadline = DateTime.UtcNow + timeout; + var gracePeriod = TimeSpan.FromMilliseconds(ProcessConstants.LauncherExitGracePeriodMs); + DateTime? launcherExitedAt = null; + + logger.LogInformation( + "[Process] Waiting up to {TimeoutMs}ms for launcher {LauncherId} to start {ExpectedName}", + (int)timeout.TotalMilliseconds, + launcher.Id, + expectedName); + try { - var processes = Process.GetProcessesByName(executableName) - .Where(p => + while (true) + { + var child = FindSpawnedGameProcess(expectedName, workingDirectory); + if (child != null) { + _managedProcesses[child.Id] = child; + try { - // Verify process was started within last 10 seconds - return (DateTime.Now - p.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds; + child.EnableRaisingEvents = true; + child.Exited += OnProcessExited; } - catch + catch (Exception ex) { - return false; + logger.LogWarning(ex, "Failed to enable raising events for adopted process {ProcessId}", child.Id); } - }) - .ToArray(); - if (processes.Length == 0) - { - return null; - } - // If multiple processes exist, try to find one with matching working directory - if (processes.Length > 1 && !string.IsNullOrEmpty(workingDirectory)) - { - foreach (var proc in processes) + logger.LogInformation( + "[Process] Adopted game process {ProcessId} ({ExpectedName}); launcher {LauncherId} is no longer tracked and its exit is ignored", + child.Id, + expectedName, + launcher.Id); + + return OperationResult.CreateSuccess(BuildProcessInfo(child, configuration.ExecutablePath)); + } + + var (launcherExited, launcherExitCode) = ReadLauncherExit(launcher); + + // A launcher that fails outright will never produce a child - do not wait it out. + if (launcherExited && launcherExitCode is int exitCode && exitCode != ProcessConstants.ExitCodeSuccess) { - try - { - var procPath = proc.MainModule?.FileName; - if (procPath != null && Path.GetDirectoryName(procPath)?.Equals(workingDirectory, StringComparison.OrdinalIgnoreCase) == true) - { - // Dispose other processes we're not using - foreach (var otherProc in processes.Where(p => p.Id != proc.Id)) - { - otherProc.Dispose(); - } + logger.LogError( + "[Process] Launcher {LauncherId} exited with code {ExitCode} before starting {ExpectedName}", + launcher.Id, + exitCode, + expectedName); + return OperationResult.CreateFailure( + AppendLauncherErrors( + $"Launcher exited with code {exitCode} before starting {expectedName}.", + launcher, + capturedErrors)); + } - return proc; - } - } - catch + // A clean exit with no child is still a failure - the bootstrapper bailing without + // launching the game looks identical to success from the exit code alone. Allow a + // short grace period for the spawn-then-enumerate race, then stop: once the + // launcher is gone a child will not appear, and waiting out the full discovery + // timeout only delays the failure and reports a misleading timeout as the cause. + if (launcherExited) + { + launcherExitedAt ??= DateTime.UtcNow; + + if (DateTime.UtcNow - launcherExitedAt.Value >= gracePeriod) { - // Cannot access process info, continue + logger.LogError( + "[Process] Launcher {LauncherId} exited cleanly without starting {ExpectedName}", + launcher.Id, + expectedName); + // The launcher has provably exited here, so the drain is safe and this + // message carries the complete stderr rather than a partial snapshot. + return OperationResult.CreateFailure( + AppendLauncherErrors( + $"Launcher exited without starting {expectedName}.", + launcher, + capturedErrors)); } } + + if (DateTime.UtcNow >= deadline) + { + logger.LogError( + "[Process] Launcher {LauncherId} did not start {ExpectedName} within {TimeoutMs}ms", + launcher.Id, + expectedName, + (int)timeout.TotalMilliseconds); + return OperationResult.CreateFailure( + AppendLauncherErrors( + $"Launcher did not start {expectedName} within {timeout.TotalSeconds:0.#}s.", + launcher, + capturedErrors)); + } + + await Task.Delay(ProcessConstants.SpawnedChildPollIntervalMs, cancellationToken); } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Matches TerminateProcessAsync, and lets GameLauncher.LaunchProfileAsync reach its + // own cancellation branch instead of reporting a generic start failure. + logger.LogInformation( + "[Process] Adoption of {ExpectedName} was cancelled; terminating launcher {LauncherId}", + expectedName, + launcher.Id); - // Return the first (or only) process found - var result = processes.First(); + TerminateAbandonedLauncher(launcher); + throw; + } + finally + { + // Releases our handle only; the launcher keeps running and owns its own lifetime. + launcher.Dispose(); + } + } - // Dispose other processes - foreach (var proc in processes.Skip(1)) + /// + /// Kills a launcher whose child was never adopted. Without this a cancelled launch leaves the + /// bootstrapper running with no tracked process and no handle for the caller to reach it. + /// + /// The launcher to terminate. + private void TerminateAbandonedLauncher(Process launcher) + { + try + { + if (launcher.HasExited) { - proc.Dispose(); + return; } - return result; + // The child may already exist but not yet be discoverable, so take the tree with it. + launcher.Kill(entireProcessTree: true); + launcher.WaitForExit(ProcessConstants.AbandonedLauncherKillWaitMs); + } + catch (Exception ex) + { + // The caller is already unwinding a cancellation; cleanup failure must not mask it. + logger.LogWarning(ex, "[Process] Failed to terminate abandoned launcher {LauncherId}", launcher.Id); + } + } + + /// + /// Builds process information, falling back to minimal details when the process cannot be read. + /// + /// The process to describe. + /// Path to report when the process cannot be inspected. + /// The process information. + private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecutablePath) + { + try + { + return new GameProcessInfo + { + ProcessId = process.Id, + ProcessName = process.ProcessName, + StartTime = process.StartTime, + ExecutablePath = GetProcessExecutablePath(process), + IsRunning = IsStillRunning(process), + }; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to get process information for {ProcessId}, using minimal info", process.Id); + return new GameProcessInfo + { + ProcessId = process.Id, + ProcessName = GameClientConstants.UnknownVersion, + StartTime = DateTime.Now, + ExecutablePath = fallbackExecutablePath, + IsRunning = IsStillRunning(process), + }; + } + } + + /// + /// Finds a spawned game process by executable name and working directory. + /// Used when a launcher executable spawns the actual game and exits. + /// + /// The base executable name without extension. + /// The expected working directory. + /// The spawned process if found, null otherwise. + private Process? FindSpawnedGameProcess(string executableName, string workingDirectory) + { + Process[] processes; + try + { + processes = Process.GetProcessesByName(executableName); } catch (Exception ex) { logger.LogWarning(ex, "Failed to find spawned game process for {ExecutableName}", executableName); return null; } + + try + { + var candidates = new List(); + foreach (var process in processes) + { + try + { + var executablePath = GetProcessExecutablePath(process); + candidates.Add(new GameProcessCandidate( + process.Id, + process.ProcessName, + process.StartTime, + string.IsNullOrEmpty(executablePath) ? null : executablePath)); + } + catch (Exception ex) + { + // A process that cannot be inspected cannot be shown to be ours. + logger.LogDebug(ex, "Skipping uninspectable process {ProcessId}", process.Id); + } + } + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, executableName, workingDirectory, DateTime.Now); + + if (selected == null) + { + foreach (var process in processes) + { + process.Dispose(); + } + + return null; + } + + var match = processes.First(process => process.Id == selected.ProcessId); + foreach (var other in processes.Where(process => process.Id != selected.ProcessId)) + { + other.Dispose(); + } + + return match; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to find spawned game process for {ExecutableName}", executableName); + foreach (var process in processes) + { + process.Dispose(); + } + + return null; + } + } + + /// + /// Reads a launcher's exit state without throwing. + /// + /// The launcher to inspect. + /// + /// Whether the launcher has exited, and its exit code when that could be read. + /// + /// + /// and throw + /// with no handle and + /// when the code cannot be read — both + /// plausible for the hard-crashing launcher this loop exists to report on. Letting either + /// escape would replace the launcher diagnosis with a generic start failure. + /// An unreadable state is reported as still running, so the loop keeps polling to its + /// deadline rather than concluding anything from a failed probe. + /// + private (bool Exited, int? ExitCode) ReadLauncherExit(Process launcher) + { + try + { + if (!launcher.HasExited) + { + return (false, null); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Process] Could not determine whether the launcher had exited"); + return (false, null); + } + + try + { + return (true, launcher.ExitCode); + } + catch (Exception ex) + { + // Exited, but the code is unavailable. The clean-exit path still applies. + logger.LogDebug(ex, "[Process] Could not read the launcher's exit code"); + return (true, null); + } + } + + /// + /// Appends whatever the launcher wrote to stderr to a failure message. + /// + /// The failure message describing what was expected. + /// The launcher process whose stderr was captured. + /// The buffer receiving the launcher's stderr lines. + /// The message, with the captured tail appended when there is one. + /// + /// Without this the adoption failures say only that the game never appeared, which is + /// the symptom rather than the cause. A bootstrapper that refuses to start the game — + /// a missing Easy Anti-Cheat installation being the expected case — explains itself on + /// stderr, and that explanation is the only thing that makes the failure actionable. + /// + private string AppendLauncherErrors(string message, Process launcher, BoundedErrorBuffer capturedErrors) + { + // Only drain once the launcher has exited. Draining waits on the stderr handlers, + // which requires the untimed WaitForExit — and on the discovery-timeout path the + // bootstrapper is still running and outlives game startup by about a minute, so + // waiting there would stall the failure long past the timeout it is reporting. + // A live launcher contributes whatever has already arrived instead. + // Broad by intent, matching DrainStandardError below. HasExited throws + // InvalidOperationException with no handle and Win32Exception when the exit code + // cannot be read — the latter being a plausible result for the hard-crashing + // launcher this method exists to report on. Letting either escape would turn a + // failure result into a thrown exception on the path describing that failure. + var launcherExited = false; + try + { + launcherExited = launcher.HasExited; + } + catch (Exception ex) + { + // No launcher property is read here: Id throws once the process is disposed, + // which is one of the states that lands in this catch to begin with. + logger.LogDebug(ex, "[Process] Could not determine whether the launcher had exited"); + } + + if (launcherExited) + { + DrainStandardError(launcher, capturedErrors); + } + + var detail = capturedErrors.ToString(); + + return string.IsNullOrWhiteSpace(detail) ? message : $"{message} {detail}"; } /// diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 10e2c0fe7..d2e6ec710 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -1192,6 +1192,10 @@ private async Task> LaunchProfileAsync(Gam WorkingDirectory = workspaceInfo.WorkspacePath, Arguments = arguments, EnvironmentVariables = BuildEnvironmentVariables(profile.EnvironmentVariables, installation), + + // Null for every client that launches its own executable; set only where a + // bootstrapper hands the session to a differently-named binary. + ExpectedChildProcessName = LaunchEntryPointResolver.ResolveExpectedChildProcessName(finalExecutablePath), }; // Before spawn, so a misconfigured root is reported with the path named. The @@ -1261,6 +1265,15 @@ private async Task> LaunchProfileAsync(Gam // This is because Windows reports the symlink target hash as the process name gameProcessName = executableFileForMonitor.Hash; logger.LogInformation("[GameLauncher] Monitoring for CAS symlinked process with hash: {Hash}", gameProcessName); + + if (!string.IsNullOrEmpty(launchConfig.ExpectedChildProcessName)) + { + // The hash names the entry point, not the binary it hands the session to, + // and the child's own hash is not available here. + logger.LogWarning( + "[GameLauncher] Launching {Entry} through a bootstrapper under CAS symlinking; monitoring may track the wrong process", + Path.GetFileName(finalExecutablePath)); + } } else { @@ -1269,6 +1282,14 @@ private async Task> LaunchProfileAsync(Gam gameProcessName = executableFileForMonitor != null ? Path.GetFileNameWithoutExtension(executableFileForMonitor.RelativePath) : Path.GetFileNameWithoutExtension(finalExecutablePath); + + // The monitored name is derived from the entry point, which may be a + // bootstrapper that exits ownership to another binary. + if (!string.IsNullOrEmpty(launchConfig.ExpectedChildProcessName)) + { + gameProcessName = launchConfig.ExpectedChildProcessName; + } + logger.LogInformation("[GameLauncher] Monitoring for process: {ProcessName}", gameProcessName); } From 3412edbc6c25914f8d973d59cb8a5b53aee4fcd0 Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:36:41 +0200 Subject: [PATCH 03/83] feat(generalsonline): create distinct ContentManifest for GeneralsOnlineGameData data patch (#373) --- .../Constants/GeneralsOnlineConstants.cs | 17 + .../GeneralsOnlineDelivererTests.cs | 577 ++++++++++++++++++ .../GeneralsOnlineManifestFactoryTests.cs | 404 ++++++++++++ .../GeneralsOnlineProfileReconcilerTests.cs | 100 ++- .../GameProfiles/StderrCaptureRaceTests.cs | 37 +- .../GameInstallationValidatorTests.cs | 7 +- .../ContentProviders/BaseContentProvider.cs | 5 + .../GeneralsOnline/GeneralsOnlineDeliverer.cs | 440 +++++++++---- .../GeneralsOnlineDependencyBuilder.cs | 77 ++- .../GeneralsOnlineJsonCatalogParser.cs | 16 +- .../GeneralsOnlineManifestFactory.cs | 230 ++++++- .../GeneralsOnlineProfileReconciler.cs | 323 +++++----- .../GeneralsOnline/GeneralsOnlineProvider.cs | 5 + .../GeneralsOnlineVariantTags.cs | 3 + docs/dev/constants.md | 74 +-- 15 files changed, 1917 insertions(+), 398 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 242cb02ef..dad16f47a 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -87,6 +87,9 @@ public static class GeneralsOnlineConstants /// Manifest name suffix for QuickMatch MapPack. public const string QuickMatchMapPackSuffix = "quickmatch-maps"; + /// Manifest name suffix for GeneralsOnlineGameData data patch. + public const string GameDataPatchSuffix = "gamedata"; + /// The default tick rate variant suffix. public const string DefaultVariantSuffix = Variant60HzSuffix; @@ -96,9 +99,18 @@ public static class GeneralsOnlineConstants /// Description for QuickMatch MapPack. public const string QuickMatchMapPackDescription = "Official map pack required for GeneralsOnline QuickMatch multiplayer. Contains competitively balanced maps."; + /// Display name for GeneralsOnlineGameData data patch. + public const string GameDataDisplayName = "GeneralsOnline Game Data"; + + /// Description for GeneralsOnlineGameData data patch. + public const string GameDataDescription = "Game data patch for GeneralsOnline containing community balance and core INI configuration."; + /// Subdirectory within the portable ZIP containing maps. public const string MapsSubdirectory = "Maps"; + /// Subdirectory within the portable ZIP containing GeneralsOnline game data. + public const string GameDataSubdirectory = "GeneralsOnlineGameData"; + // ===== Component Identifiers ===== /// Source name for Generals Online discoverer. @@ -122,4 +134,9 @@ public static class GeneralsOnlineConstants /// Default tags for MapPack manifests. /// public static readonly string[] MapPackTags = ["mappack", "generalsonline", "quickmatch", "competitive"]; + + /// + /// Default tags for GameData patch manifests. + /// + public static readonly string[] GameDataTags = ["patch", "generalsonline"]; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs new file mode 100644 index 000000000..4e5d7c096 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs @@ -0,0 +1,577 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for . +/// +public class GeneralsOnlineDelivererTests : IDisposable +{ + private readonly Mock _downloadServiceMock; + private readonly Mock _manifestPoolMock; + private readonly Mock _providerLoaderMock; + private readonly GeneralsOnlineManifestFactory _manifestFactory; + private readonly GeneralsOnlineDeliverer _deliverer; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineDelivererTests() + { + _downloadServiceMock = new Mock(); + _manifestPoolMock = new Mock(); + _providerLoaderMock = new Mock(); + + _providerLoaderMock + .Setup(l => l.GetProvider(PublisherTypeConstants.GeneralsOnline)) + .Returns(new ProviderDefinition + { + ProviderId = PublisherTypeConstants.GeneralsOnline, + PublisherType = PublisherTypeConstants.GeneralsOnline, + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://example.com/go", + }, + }); + + _manifestPoolMock + .Setup(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(false)); + + _manifestFactory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + _providerLoaderMock.Object); + + _deliverer = new GeneralsOnlineDeliverer( + _downloadServiceMock.Object, + _manifestPoolMock.Object, + _manifestFactory, + NullLogger.Instance); + + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_GODelivererTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up test artifacts. + /// + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies CanDeliver returns true for GeneralsOnline manifests with zip downloads. + /// + [Fact] + public void CanDeliver_ValidGeneralsOnlineManifest_ReturnsTrue() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.True(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies CanDeliver returns false for other publishers. + /// + [Fact] + public void CanDeliver_OtherPublisher_ReturnsFalse() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo { PublisherType = "other-publisher" }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/other.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.False(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies CanDeliver returns false for GeneralsOnline manifests without a ZIP download URL. + /// + [Fact] + public void CanDeliver_ManifestWithoutZipFile_ReturnsFalse() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline.exe", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.False(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies DeliverContentAsync fails when any manifest registration in pool fails. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_WhenManifestRegistrationFails_ReturnsFailureAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First manifest registration succeeds, second fails + var callCount = 0; + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 + ? OperationResult.CreateSuccess(true) + : OperationResult.CreateFailure("Simulated pool registration failure"); + }); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Contains("Simulated pool registration failure", result.FirstError); + + // Verifies that earlier successfully registered manifest was rolled back + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts should be cleaned up on failure + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies the happy path of DeliverContentAsync: all manifests register, + /// files are moved to target directory, and temporary extraction directory is cleaned up. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_HappyPath_RegistersAllManifestsAndCleansUpAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "happy_test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "happy_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + // Exactly 2 manifests were registered in pool (GameClient and GameData Patch; empty MapPack is skipped) + _manifestPoolMock.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + + // Rollback was never invoked on the happy path + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + // Files were moved to target directory + Assert.True(File.Exists(Path.Combine(targetDir, "generalsonlinezh_60.exe"))); + Assert.True(File.Exists(Path.Combine(targetDir, "GeneralsOnlineGameData", "500_900_CommunityPatch_CoreINI.big"))); + + // Downloaded ZIP and temporary extracted directory were cleaned up + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies that if manifest acquisition check fails, rollback is triggered and failure is returned. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_CheckAcquisitionFails_RollsBackAndReturnsFailureAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "check_fail.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First check succeeds, second check fails + _manifestPoolMock + .SetupSequence(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(false)) + .ReturnsAsync(OperationResult.CreateFailure("CAS index corrupted")); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "check_fail_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Contains("Failed to check manifest acquisition status", result.FirstError); + + // First manifest was rolled back + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts should be cleaned up on failure + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies that already-acquired manifests are skipped during registration. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_ManifestAlreadyAcquired_SkipsRegistrationAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "already_acquired.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First manifest is already acquired, second is not + _manifestPoolMock + .SetupSequence(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)) + .ReturnsAsync(OperationResult.CreateSuccess(false)); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "already_acquired_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.True(result.Success); + + // AddManifestAsync called only once (for the unacquired Patch manifest, skipping GameClient) + _manifestPoolMock.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that cancellation during manifest registration triggers rollback, cleans temp artifacts, and rethrows OperationCanceledException. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_CancellationDuringRegistration_RollsBackAndRethrowsAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "cancel_test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + using var cts = new CancellationTokenSource(); + + var callCount = 0; + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return OperationResult.CreateSuccess(true); + } + + cts.Cancel(); + throw new OperationCanceledException(cts.Token); + }); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act & Assert + var targetDir = Path.Combine(_tempDir, "cancel_delivery"); + Directory.CreateDirectory(targetDir); + + await Assert.ThrowsAsync( + () => _deliverer.DeliverContentAsync(manifest, targetDir, null, cts.Token)); + + // Rollback was invoked for the earlier registered manifest + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts were cleaned up + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + private static void CreateTestZip(string zipPath) + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + CreateEntryWithText(archive, "generalsonlinezh_60.exe", "fake content"); + CreateEntryWithText(archive, "GeneralsOnlineGameData/500_900_CommunityPatch_CoreINI.big", "fake big content"); + } + + private static void CreateEntryWithText(ZipArchive archive, string entryName, string content) + { + var entry = archive.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs new file mode 100644 index 000000000..b2a4c5882 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs @@ -0,0 +1,404 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GeneralsOnline; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for and related dependency creation. +/// +public class GeneralsOnlineManifestFactoryTests : IDisposable +{ + private readonly Mock _providerLoaderMock; + private readonly GeneralsOnlineManifestFactory _factory; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineManifestFactoryTests() + { + _providerLoaderMock = new Mock(); + _providerLoaderMock + .Setup(l => l.GetProvider(PublisherTypeConstants.GeneralsOnline)) + .Returns(new ProviderDefinition + { + ProviderId = PublisherTypeConstants.GeneralsOnline, + PublisherType = PublisherTypeConstants.GeneralsOnline, + Description = "Community multiplayer for Generals Zero Hour", + DefaultTags = ["multiplayer", "online"], + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://example.com/go", + }, + }); + + _factory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + _providerLoaderMock.Object); + + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_GOTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up temporary test directory. + /// + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that generates 3 manifests: + /// 60Hz GameClient, QuickMatch MapPack, and GeneralsOnlineGameData data patch. + /// + [Fact] + public void CreateManifests_GeneratesThreeManifests_IncludingGameDataPatch() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip", + PortableSize = 1048576, + Changelog = "https://example.com/changelog", + }; + + // Act + var manifests = _factory.CreateManifests(release); + + // Assert + Assert.Equal(3, manifests.Count); + + var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + var mapPack = manifests.FirstOrDefault(m => m.ContentType == ContentType.MapPack); + var gameDataPatch = manifests.FirstOrDefault(m => m.ContentType == ContentType.Patch); + + Assert.NotNull(gameClient); + Assert.NotNull(mapPack); + Assert.NotNull(gameDataPatch); + + // Verify GameClient manifest + Assert.Contains(GeneralsOnlineConstants.Variant60HzSuffix, gameClient.Id.Value); + Assert.Equal(GameType.ZeroHour, gameClient.TargetGame); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, gameClient.Name); + + // Verify MapPack manifest + Assert.Contains("quickmatchmaps", mapPack.Id.Value); + Assert.Equal(GameType.ZeroHour, mapPack.TargetGame); + Assert.Equal(GeneralsOnlineConstants.QuickMatchMapPackDisplayName, mapPack.Name); + + // Verify GameData Patch manifest + Assert.Contains(GeneralsOnlineConstants.GameDataPatchSuffix, gameDataPatch.Id.Value); + Assert.Equal(ContentType.Patch, gameDataPatch.ContentType); + Assert.Equal(GameType.ZeroHour, gameDataPatch.TargetGame); + Assert.Equal(GeneralsOnlineConstants.GameDataDisplayName, gameDataPatch.Name); + Assert.Equal(GeneralsOnlineConstants.GameDataDescription, gameDataPatch.Metadata?.Description); + Assert.Contains(GeneralsOnlineVariantTags.TagGameData, gameDataPatch.Metadata?.Tags ?? []); + } + + /// + /// Verifies that the GameData patch depends on the 60Hz GameClient and Zero Hour, + /// while the 60Hz GameClient does not depend on the GameData patch (making GameData patch optional). + /// + [Fact] + public void Dependencies_GameDataPatch_DependsOn60HzGameClientAndZeroHour_WhileGameClientDoesNotDependOnGameData() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/test.zip", + }; + + // Act + var manifests = _factory.CreateManifests(release); + var gameClient = manifests.First(m => m.ContentType == ContentType.GameClient); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Assert - GameData patch has dependencies on Zero Hour and 60Hz GameClient + Assert.NotEmpty(gameDataPatch.Dependencies); + var zhDepInPatch = gameDataPatch.Dependencies.FirstOrDefault(d => d.DependencyType == ContentType.GameInstallation); + var clientDepInPatch = gameDataPatch.Dependencies.FirstOrDefault(d => d.DependencyType == ContentType.GameClient); + + Assert.NotNull(zhDepInPatch); + Assert.NotNull(clientDepInPatch); + Assert.Equal(gameClient.Id.Value, clientDepInPatch.Id.Value); + Assert.False(clientDepInPatch.IsOptional); + Assert.True(clientDepInPatch.StrictPublisher); + Assert.Equal(PublisherTypeConstants.GeneralsOnline, clientDepInPatch.PublisherType); + + // Assert - GameClient dependencies do NOT include Patch dependency + Assert.DoesNotContain(gameClient.Dependencies, d => d.DependencyType == ContentType.Patch); + Assert.DoesNotContain(gameClient.Dependencies, d => d.Id.Value.Contains(GeneralsOnlineConstants.GameDataPatchSuffix)); + } + + /// + /// Verifies that returns true for GameClient, MapPack, and Patch. + /// + [Fact] + public void CanHandle_WithValidManifestTypes_ReturnsTrue() + { + // Arrange + var publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }; + + var clientManifest = new ContentManifest { ContentType = ContentType.GameClient, Publisher = publisher }; + var mapPackManifest = new ContentManifest { ContentType = ContentType.MapPack, Publisher = publisher }; + var patchManifest = new ContentManifest { ContentType = ContentType.Patch, Publisher = publisher }; + var otherPublisherManifest = new ContentManifest { ContentType = ContentType.Patch, Publisher = new PublisherInfo { PublisherType = "other" } }; + var otherTypeManifest = new ContentManifest { ContentType = ContentType.Mod, Publisher = publisher }; + + // Act & Assert + Assert.True(_factory.CanHandle(clientManifest)); + Assert.True(_factory.CanHandle(mapPackManifest)); + Assert.True(_factory.CanHandle(patchManifest)); + Assert.False(_factory.CanHandle(otherPublisherManifest)); + Assert.False(_factory.CanHandle(otherTypeManifest)); + } + + /// + /// Verifies that separates files + /// correctly among GameClient, MapPack, and GameData Patch manifests. + /// + /// A representing the test execution. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_SeparatesFilesCorrectly() + { + // Arrange: Create simulated extracted directory structure + var exePath = Path.Combine(_tempDir, GameClientConstants.GeneralsOnline60HzExecutable); + var dllPath = Path.Combine(_tempDir, "GameNetworkingSockets.dll"); + File.WriteAllText(exePath, "fake exe content"); + File.WriteAllText(dllPath, "fake dll content"); + + var mapsDir = Path.Combine(_tempDir, GeneralsOnlineConstants.MapsSubdirectory, "Tournament Desert"); + Directory.CreateDirectory(mapsDir); + var mapFilePath = Path.Combine(mapsDir, "Tournament Desert.map"); + File.WriteAllText(mapFilePath, "fake map content"); + + var gameDataDir = Path.Combine(_tempDir, GeneralsOnlineConstants.GameDataSubdirectory); + Directory.CreateDirectory(gameDataDir); + var bigPath = Path.Combine(gameDataDir, "500_900_CommunityPatch_CoreINI.big"); + File.WriteAllText(bigPath, "fake big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Metadata = new ContentMetadata { ReleaseDate = DateTime.UtcNow }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None); + + // Assert + Assert.Equal(3, manifests.Count); + + var gameClient = manifests.First(m => m.ContentType == ContentType.GameClient); + var mapPack = manifests.First(m => m.ContentType == ContentType.MapPack); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Check GameClient files + Assert.Equal(2, gameClient.Files.Count); + Assert.Contains(gameClient.Files, f => f.RelativePath == GameClientConstants.GeneralsOnline60HzExecutable && f.IsExecutable && f.InstallTarget == ContentInstallTarget.Workspace); + Assert.Contains(gameClient.Files, f => f.RelativePath == "GameNetworkingSockets.dll" && !f.IsExecutable && f.InstallTarget == ContentInstallTarget.Workspace); + Assert.DoesNotContain(gameClient.Files, f => f.RelativePath.Contains("Maps")); + Assert.DoesNotContain(gameClient.Files, f => f.RelativePath.Contains("GeneralsOnlineGameData")); + + // Check MapPack files + Assert.Single(mapPack.Files); + var mapFile = mapPack.Files[0]; + Assert.Equal(ContentInstallTarget.UserMapsDirectory, mapFile.InstallTarget); + Assert.False(mapFile.IsExecutable); + Assert.EndsWith(".map", mapFile.RelativePath, StringComparison.OrdinalIgnoreCase); + Assert.False(mapFile.RelativePath.StartsWith("Maps", StringComparison.OrdinalIgnoreCase)); + + // Check GameData patch files + Assert.Single(gameDataPatch.Files); + Assert.All(gameDataPatch.Files, f => + { + Assert.Equal(ContentInstallTarget.Workspace, f.InstallTarget); + Assert.False(f.IsExecutable); + Assert.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory, f.RelativePath, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(f.Hash); + }); + Assert.Contains(gameDataPatch.Files, f => f.RelativePath.EndsWith("500_900_CommunityPatch_CoreINI.big", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that directories with names starting with "Maps" or "GeneralsOnlineGameData" (e.g. Maps_backup, GeneralsOnlineGameData_backup) + /// are not misclassified as Maps or GeneralsOnlineGameData. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_SiblingDirectories_AreNotMisclassified() + { + // Arrange + var siblingMapDir = Path.Combine(_tempDir, "Maps_backup"); + Directory.CreateDirectory(siblingMapDir); + File.WriteAllText(Path.Combine(siblingMapDir, "backup.map"), "fake map backup"); + + var siblingGameDataDir = Path.Combine(_tempDir, "GeneralsOnlineGameData_backup"); + Directory.CreateDirectory(siblingGameDataDir); + File.WriteAllText(Path.Combine(siblingGameDataDir, "backup.ini"), "fake ini backup"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Metadata = new ContentMetadata { ReleaseDate = DateTime.UtcNow }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None); + + // Assert - MapPack and GameData patch are omitted because they have 0 files + Assert.Single(manifests); + var gameClient = manifests.Single(); + Assert.Equal(ContentType.GameClient, gameClient.ContentType); + Assert.DoesNotContain(manifests, m => m.ContentType == ContentType.MapPack); + Assert.DoesNotContain(manifests, m => m.ContentType == ContentType.Patch); + + // Assert - GameClient must contain the sibling files as workspace files + Assert.Contains(gameClient.Files, f => f.RelativePath.Contains("Maps_backup")); + Assert.Contains(gameClient.Files, f => f.RelativePath.Contains("GeneralsOnlineGameData_backup")); + } + + /// + /// Verifies that throws + /// when passed a cancelled token. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreCancelledToken_ThrowsOperationCanceledException() + { + // Arrange + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => + _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, cts.Token)); + } + + /// + /// Verifies that GameData patch metadata tags do not contain duplicate tags. + /// + [Fact] + public void CreateManifests_GameDataPatchTags_HasNoDuplicateTags() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/test.zip", + }; + + // Act + var manifests = _factory.CreateManifests(release); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Assert + var tags = gameDataPatch.Metadata?.Tags ?? []; + var distinctTags = tags.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Assert.Equal(distinctTags.Count, tags.Count); + Assert.Contains("gamedata", tags); + Assert.Contains("patch", tags); + Assert.Contains("generalsonline", tags); + } + + /// + /// Verifies that throws + /// when the GameClient manifest has zero files. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EmptyGameClient_ThrowsInvalidDataException() + { + // Arrange: Only create map files, no GameClient files + var mapsDir = Path.Combine(_tempDir, GeneralsOnlineConstants.MapsSubdirectory, "TestMap"); + Directory.CreateDirectory(mapsDir); + File.WriteAllText(Path.Combine(mapsDir, "TestMap.map"), "fake map"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None)); + } + + /// + /// Verifies directly returns the expected GameData dependencies. + /// + [Fact] + public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependencies() + { + // Arrange + var expectedClientId = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"); + + // Act + var dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(1015255); + + // Assert + Assert.Equal(2, dependencies.Count); + Assert.Contains(dependencies, d => d.DependencyType == ContentType.GameInstallation); + var clientDep = dependencies.First(d => d.DependencyType == ContentType.GameClient); + Assert.Equal(expectedClientId.Value, clientDep.Id.Value); + + var builder = new GeneralsOnlineDependencyBuilder(); + var patchManifest = new ContentManifest + { + Version = "101525_QFE5", + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + var resolvedDeps = builder.GetDependencies(patchManifest); + Assert.Equal(2, resolvedDeps.Count); + Assert.Contains(resolvedDeps, d => d.DependencyType == ContentType.GameInstallation); + var resolvedClientDep = resolvedDeps.First(d => d.DependencyType == ContentType.GameClient); + Assert.Equal(expectedClientId.Value, resolvedClientDep.Id.Value); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs index ac8f9f7db..9734bbafc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs @@ -17,6 +17,7 @@ using GenHub.Tests.Core.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; @@ -80,7 +81,7 @@ public GeneralsOnlineProfileReconcilerTests() /// /// A task representing the asynchronous operation. [Fact] - public async Task CheckAndReconcile_ShouldIgnore_LocalManifests() + public async Task CheckAndReconcile_ShouldIgnore_LocalManifestsAsync() { // Arrange string latestVersion = "0.0.99"; @@ -145,7 +146,7 @@ public async Task CheckAndReconcile_ShouldIgnore_LocalManifests() /// /// A task representing the asynchronous operation. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellation() + public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellationAsync() { // Arrange string latestVersion = "0.0.99"; @@ -180,4 +181,99 @@ await Assert.ThrowsAsync( x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + /// + /// Verifies that old and new GameData patch manifests are recognized by variant and included in reconciliation mapping. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task CheckAndReconcileIfNeededAsync_WithGameDataPatchManifest_MapsAndReconcilesGameDataPatchAsync() + { + // Arrange + const string oldVersion = "101524"; + const string newVersion = "101525"; + + _updateServiceMock.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) + .ReturnsAsync(ContentUpdateCheckResult.CreateUpdateAvailable(newVersion, oldVersion)); + + var settings = new UserSettings(); + settings.SetAutoUpdatePreference(GeneralsOnlineConstants.PublisherType, true); + settings.GetOrCreateSubscription(GeneralsOnlineConstants.PublisherType).DeleteOldVersions = true; + _userSettingsServiceMock.Setup(x => x.Get()) + .Returns(settings); + + var oldClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.101524.generalsonline.gameclient.60hz"), + Version = oldVersion, + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + }; + + var oldPatchManifest = new ContentManifest + { + Id = ManifestId.Create("1.101524.generalsonline.patch.gamedata"), + Version = oldVersion, + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["gamedata", "patch", "generalsonline"] }, + }; + + var newClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.101525.generalsonline.gameclient.60hz"), + Version = newVersion, + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + }; + + var newPatchManifest = new ContentManifest + { + Id = ManifestId.Create("1.101525.generalsonline.patch.gamedata"), + Version = newVersion, + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["gamedata", "patch", "generalsonline"] }, + }; + + _manifestPoolMock.SetupSequence(x => x.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([oldClientManifest, oldPatchManifest])) + .ReturnsAsync(OperationResult>.CreateSuccess([oldClientManifest, oldPatchManifest, newClientManifest, newPatchManifest])); + + _contentOrchestratorMock.Setup( + x => x.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess( + [ + new() { Name = "New GO Version", Version = newVersion }, + ])); + + _contentOrchestratorMock.Setup(x => x.AcquireContentAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(newClientManifest)); + + IReadOnlyDictionary? capturedMapping = null; + _reconciliationServiceMock + .Setup(x => x.OrchestrateBulkUpdateAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, bool, CancellationToken>((mapping, createNew, token) => capturedMapping = mapping) + .ReturnsAsync(OperationResult.CreateSuccess(new ReconciliationResult(1, 0))); + + // Act + var result = await _reconciler.CheckAndReconcileIfNeededAsync("profile1", CancellationToken.None); + + // Assert + Assert.True(result.Success, $"Reconciliation failed: {result.FirstError}"); + Assert.NotNull(capturedMapping); + Assert.True(capturedMapping.ContainsKey(oldClientManifest.Id.Value)); + Assert.Equal(newClientManifest.Id.Value, capturedMapping[oldClientManifest.Id.Value]); + Assert.True(capturedMapping.ContainsKey(oldPatchManifest.Id.Value)); + Assert.Equal(newPatchManifest.Id.Value, capturedMapping[oldPatchManifest.Id.Value]); + + _reconciliationServiceMock.Verify( + x => x.OrchestrateBulkRemovalAsync( + It.Is>(ids => ids.Contains(oldClientManifest.Id) && ids.Contains(oldPatchManifest.Id)), + It.IsAny()), + Times.Once); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs index 3aef47b33..494345b3a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs @@ -35,40 +35,35 @@ public class StderrCaptureRaceTests /// /// A task representing the asynchronous test operation. [Fact] - public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderr() + public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderrAsync() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return; } - // Arguments are key/value pairs; a leading '-' key is emitted as a flag followed - // by its value, which produces `/bin/sh -c