diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
index dad16f47a..bebc15802 100644
--- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
@@ -125,6 +125,23 @@ public static class GeneralsOnlineConstants
/// Description for Generals Online deliverer.
public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage";
+ // ===== Easy Anti-Cheat Installation =====
+
+ /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online.
+ public const string EacProductId = "fc1cc0d936424212b645105f084d08b0";
+
+ /// Setup command passed to EasyAntiCheat_EOS_Setup.exe.
+ public const string EacInstallCommand = "install";
+
+ /// Display name for the Easy Anti-Cheat installation step.
+ public const string EacStepName = "Install Easy Anti-Cheat";
+
+ /// Status message displayed to the user during Easy Anti-Cheat installation.
+ public const string EacStatusMessage = "Installing AntiCheat";
+
+ /// Unique step key identifying Easy Anti-Cheat installation for Generals Online.
+ public const string EacStepKey = PublisherType + ":eac:" + EacProductId;
+
// ===== Content Tags =====
/// Content tags for search and categorization.
diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
index 27f2cd99a..5ca6cabfb 100644
--- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
+++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using GenHub.Core.Extensions.GameInstallations;
using GenHub.Core.Models.Enums;
@@ -59,6 +61,19 @@ public static class PublisherTypeConstants
/// Art of Defense Maps community site.
public const string AODMaps = "aodmaps";
+ /// GenHub internal system content publisher.
+ public const string GenHubInternal = "genhub";
+
+ ///
+ /// Set of publisher identifiers trusted to execute installation steps (e.g. installers).
+ ///
+ public static readonly IReadOnlySet TrustedExecutablePublishers = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ GeneralsOnline,
+ CommunityOutpost,
+ TheSuperHackers,
+ };
+
///
/// Maps GameInstallationType enum to publisher type string.
///
diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs
index 288f86fe2..8b59e6eff 100644
--- a/GenHub/GenHub.Core/Helpers/PathHelper.cs
+++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs
@@ -43,7 +43,23 @@ public static bool AreSamePath(string first, string second)
Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)),
PathComparison);
}
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException)
+ catch (IOException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (SecurityException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (NotSupportedException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (ArgumentException)
{
return string.Equals(first, second, PathComparison);
}
@@ -76,11 +92,41 @@ public static string GetSafeParentDirectory(string path)
/// when the candidate resolves inside the base directory; otherwise, .
public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath)
{
- var normalizedRoot = Path.GetFullPath(baseDirectory);
- var normalizedTarget = Path.GetFullPath(candidatePath);
+ if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var normalizedRoot = Path.GetFullPath(baseDirectory);
+ var normalizedTarget = Path.GetFullPath(candidatePath);
- return IsContained(normalizedRoot, normalizedTarget) &&
- IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget));
+ return IsContained(normalizedRoot, normalizedTarget) &&
+ IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget));
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Normalizes a relative path by standardizing directory separators and removing leading separators.
+ ///
+ /// The relative path to normalize.
+ /// The normalized relative path.
+ public static string NormalizeRelativePath(string relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ return string.Empty;
+ }
+
+ return relativePath
+ .Replace('\\', '/')
+ .TrimStart('/')
+ .Replace('/', Path.DirectorySeparatorChar);
}
private static bool IsContained(string normalizedRoot, string normalizedTarget)
@@ -93,31 +139,52 @@ private static bool IsContained(string normalizedRoot, string normalizedTarget)
!Path.IsPathRooted(relative);
}
- private static string FollowLinks(string fullPath)
+ private static string FollowLinks(string fullPath, int maxDepth = 32)
{
+ if (maxDepth <= 0)
+ {
+ return fullPath;
+ }
+
try
{
- var existing = fullPath;
- var remainder = string.Empty;
+ var normalized = Path.GetFullPath(fullPath);
+ var root = Path.GetPathRoot(normalized);
+ if (string.IsNullOrEmpty(root))
+ {
+ return normalized;
+ }
+
+ var relativeFromRoot = Path.GetRelativePath(root, normalized);
+ if (relativeFromRoot == "." || relativeFromRoot.Length == 0)
+ {
+ return root;
+ }
+
+ var segments = relativeFromRoot.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
- while (!Directory.Exists(existing) && !File.Exists(existing))
+ var current = root;
+ foreach (var segment in segments)
{
- var parent = Path.GetDirectoryName(existing);
- if (string.IsNullOrEmpty(parent))
+ current = Path.Combine(current, segment);
+
+ if (Directory.Exists(current) || File.Exists(current))
{
- return fullPath;
- }
+ FileSystemInfo info = Directory.Exists(current)
+ ? new DirectoryInfo(current)
+ : new FileInfo(current);
- remainder = Path.Combine(Path.GetFileName(existing), remainder);
- existing = parent;
+ var target = info.ResolveLinkTarget(returnFinalTarget: true);
+ if (target != null)
+ {
+ current = FollowLinks(target.FullName, maxDepth - 1);
+ }
+ }
}
- FileSystemInfo info = Directory.Exists(existing)
- ? new DirectoryInfo(existing)
- : new FileInfo(existing);
- var resolved = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? existing;
-
- return remainder.Length == 0 ? resolved : Path.GetFullPath(Path.Combine(resolved, remainder));
+ return Path.GetFullPath(current);
}
catch (IOException)
{
@@ -127,6 +194,10 @@ private static string FollowLinks(string fullPath)
{
return fullPath;
}
+ catch (SecurityException)
+ {
+ return fullPath;
+ }
catch (NotSupportedException)
{
return fullPath;
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs
new file mode 100644
index 000000000..74f9cd2a3
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for validating and executing manifest-declared installation steps.
+///
+public interface IInstallationInstructionsService
+{
+ ///
+ /// Executes post-installation steps for the specified manifest, optionally forcing run-once steps.
+ ///
+ /// The content manifest declaring post-installation steps.
+ /// The working directory containing the content files.
+ /// The provider source name supplying the content, used for step authorization.
+ /// Whether to force execution of steps marked as run-once even if already executed.
+ /// Optional progress reporter for acquisition status.
+ /// A token to cancel the operation.
+ /// A result indicating whether all post-installation steps succeeded.
+ Task ExecutePostInstallStepsAsync(
+ ContentManifest manifest,
+ string workingDirectory,
+ string? providerSource = null,
+ bool force = false,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs
new file mode 100644
index 000000000..f4ef7d82f
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs
@@ -0,0 +1,27 @@
+using GenHub.Core.Models.Manifest;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Defines a precondition or environment check for an installation step.
+/// Allows domain-specific probes (e.g. system service or installed anti-cheat detection)
+/// to determine whether a step is already satisfied.
+///
+public interface IInstallationStepPrecondition
+{
+ ///
+ /// Determines whether this precondition can handle the specified installation step.
+ ///
+ /// The installation step to inspect.
+ /// The content manifest declaring the step.
+ /// if this precondition applies to the step; otherwise, .
+ bool CanHandle(InstallationStep step, ContentManifest manifest);
+
+ ///
+ /// Determines whether the step's goal is already fulfilled in the local environment.
+ ///
+ /// The installation step to evaluate.
+ /// The content manifest declaring the step.
+ /// if the step is already fulfilled; otherwise, .
+ bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
index e707f019e..58985d1e0 100644
--- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
+++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
@@ -86,6 +86,13 @@ public interface IContentManifestBuilder
/// The builder instance for chaining.
IContentManifestBuilder WithPublisher(string name, string website = "", string supportUrl = "", string contactEmail = "", string publisherType = "");
+ ///
+ /// Sets publisher information from an existing instance.
+ ///
+ /// The publisher information.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithPublisher(PublisherInfo publisher);
+
///
/// Sets content metadata.
///
@@ -207,26 +214,42 @@ IContentManifestBuilder AddDependency(
IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy);
///
- /// Adds a pre-installation step.
+ /// Sets the complete installation instructions object for the manifest.
///
- /// Step name.
- /// Command to execute.
- /// Command arguments.
- /// Working directory for the command.
- /// Whether elevation is required.
+ /// The installation instructions object.
/// The builder instance for chaining.
- IContentManifestBuilder AddPreInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false);
+ IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions);
///
/// Adds a post-installation step.
///
/// Step name.
- /// Command to execute.
- /// Command arguments.
- /// Working directory for the command.
+ /// The kind of installation step to execute.
+ /// Target relative path within workspace.
+ /// Command arguments for executable steps.
+ /// Destination relative path for rename operations.
/// Whether elevation is required.
+ /// Optional user-facing status message.
+ /// Whether to execute only once and skip on future updates.
+ /// Optional unique step key for tracking execution.
/// The builder instance for chaining.
- IContentManifestBuilder AddPostInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false);
+ IContentManifestBuilder AddPostInstallStep(
+ string name,
+ InstallationStepKind kind,
+ string? targetRelativePath = null,
+ List? arguments = null,
+ string? destinationRelativePath = null,
+ bool requiresElevation = false,
+ string? statusMessage = null,
+ bool runOnce = false,
+ string? stepKey = null);
+
+ ///
+ /// Adds a post-installation step using an existing instance.
+ ///
+ /// The installation step to add.
+ /// The builder instance for chaining.
+ IContentManifestBuilder AddPostInstallStep(InstallationStep step);
///
/// Adds a content reference for cross-publisher linking.
@@ -244,6 +267,13 @@ IContentManifestBuilder AddContentReference(
string minVersion = "",
string maxVersion = "");
+ ///
+ /// Sets content references for cross-publisher linking.
+ ///
+ /// The collection of content references.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithContentReferences(IEnumerable contentReferences);
+
///
/// Adds a file patching operation to the manifest.
///
diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
index c33263307..fd4c33fe7 100644
--- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs
+++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
@@ -95,6 +95,11 @@ public class UserSettings
///
public CasConfiguration CasConfiguration { get; set; } = new();
+ ///
+ /// Gets or sets the collection of installation step keys that have been executed on this machine.
+ ///
+ public HashSet ExecutedInstallationSteps { get; set; } = [];
+
/// Marks a property as explicitly set by the user.
/// The name of the property to mark as explicitly set.
public void MarkAsExplicitlySet(string propertyName)
@@ -102,6 +107,29 @@ public void MarkAsExplicitlySet(string propertyName)
ExplicitlySetProperties.Add(propertyName);
}
+ ///
+ /// Checks whether an installation step key has already been recorded as executed.
+ ///
+ /// The unique installation step key.
+ /// if already executed; otherwise, .
+ public bool IsInstallationStepExecuted(string stepKey)
+ {
+ return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey);
+ }
+
+ ///
+ /// Records that an installation step key has been executed.
+ ///
+ /// The unique installation step key.
+ public void RecordInstallationStepExecuted(string stepKey)
+ {
+ if (!string.IsNullOrWhiteSpace(stepKey))
+ {
+ ExecutedInstallationSteps ??= [];
+ ExecutedInstallationSteps.Add(stepKey);
+ }
+ }
+
/// Checks if a property was explicitly set by the user.
/// The name of the property to check.
/// true if the property was explicitly set by the user; otherwise, false.
@@ -181,6 +209,7 @@ public UserSettings Clone()
UseInstallationAdjacentStorage = UseInstallationAdjacentStorage,
ExplicitlySetProperties = [.. ExplicitlySetProperties],
CasConfiguration = (CasConfiguration?)CasConfiguration?.Clone() ?? new CasConfiguration(),
+ ExecutedInstallationSteps = ExecutedInstallationSteps != null ? [.. ExecutedInstallationSteps] : [],
SkippedUpdateVersions = SkippedUpdateVersions != null ? new Dictionary(SkippedUpdateVersions) : [],
PreferredUpdateStrategy = PreferredUpdateStrategy,
PublisherSubscriptions = PublisherSubscriptions != null
diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs
new file mode 100644
index 000000000..1389e130b
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace GenHub.Core.Models.Enums;
+
+///
+/// Defines the supported kind of installation operation in manifest-declared installation steps.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum InstallationStepKind
+{
+ ///
+ /// Installation step kind is unknown or undefined (default).
+ ///
+ Unknown = 0,
+
+ ///
+ /// Runs a verified installer executable that exists within the manifest and workspace.
+ ///
+ RunVerifiedInstaller = 1,
+
+ ///
+ /// Removes a file within the workspace.
+ ///
+ RemoveFile = 2,
+
+ ///
+ /// Renames or moves a file within the workspace.
+ ///
+ RenameFile = 3,
+}
diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
index fd42ee2b2..b3a98df3a 100644
--- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
+++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
@@ -36,6 +36,12 @@ public class GeneralsOnlineRelease
///
public long? PortableSize { get; init; }
+ ///
+ /// Gets SHA256 hash of the portable ZIP package for file verification.
+ /// Null when hash is unknown (e.g., from latest.txt API).
+ ///
+ public string? Sha256 { get; init; }
+
///
/// Gets release changelog/notes.
///
diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
index 1c5e1dba6..b954fcdbd 100644
--- a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
@@ -10,11 +10,6 @@ namespace GenHub.Core.Models.Manifest;
///
public class InstallationInstructions
{
- ///
- /// Gets or sets the steps to run before installation.
- ///
- public List PreInstallSteps { get; set; } = [];
-
///
/// Gets or sets the steps to run after installation.
///
diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
index 6ecd505a9..78590ebfd 100644
--- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
@@ -1,7 +1,11 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using GenHub.Core.Models.Enums;
+
namespace GenHub.Core.Models.Manifest;
///
-/// Individual installation step with commands and conditions.
+/// Individual installation step with typed operation kind and structured parameters.
///
public class InstallationStep
{
@@ -11,22 +15,49 @@ public class InstallationStep
public string Name { get; set; } = string.Empty;
///
- /// Gets or sets the command to execute.
+ /// Gets or sets the kind of installation operation to execute.
+ ///
+ public InstallationStepKind Kind { get; set; } = InstallationStepKind.Unknown;
+
+ ///
+ /// Gets or sets the relative path of the target file to act upon in the delivered workspace or manifest.
///
- public string Command { get; set; } = string.Empty;
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? TargetRelativePath { get; set; }
///
- /// Gets or sets the arguments for the command.
+ /// Gets or sets the destination relative path when renaming or moving a file.
+ /// Only used when is .
///
- public List Arguments { get; set; } = new();
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DestinationRelativePath { get; set; }
///
- /// Gets or sets the working directory for the command.
+ /// Gets or sets the arguments for executable steps.
+ /// Only used when is .
///
- public string? WorkingDirectory { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public List? Arguments { get; set; }
///
/// Gets or sets a value indicating whether the step requires elevation.
///
public bool RequiresElevation { get; set; }
+
+ ///
+ /// Gets or sets an optional user-facing status message to display in notifications or progress.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? StatusMessage { get; set; }
+
+ ///
+ /// Gets or sets an optional unique key identifying this installation step for execution tracking across updates.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? StepKey { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this step should only run once and be skipped on subsequent updates if already executed.
+ ///
+ public bool RunOnce { get; set; }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
index 21b6cd066..3be9893e8 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
@@ -1,11 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
using GenHub.Features.Content.Services.ContentProviders;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
namespace GenHub.Tests.Core.Features.Content;
@@ -15,14 +22,15 @@ namespace GenHub.Tests.Core.Features.Content;
public class BaseContentProviderTests
{
///
- /// Verifies that PrepareContentAsync validates manifest before preparation.
+ /// Verifies that PrepareContentAsync validates manifest before preparation and executes post-install steps.
///
/// A task representing the asynchronous operation.
[Fact]
- public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
+ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallStepsAsync()
{
// Arrange
var validatorMock = new Mock();
+ var instructionsMock = new Mock();
var loggerMock = new Mock();
var discovererMock = new Mock();
var resolverMock = new Mock();
@@ -40,7 +48,22 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
})
.ReturnsAsync(validationResult);
- var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object);
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess());
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
// Act
var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
@@ -48,9 +71,101 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
// Assert
Assert.True(result.Success);
validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once);
+ instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once);
validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once);
}
+ ///
+ /// Verifies that PrepareContentAsync fails and triggers rollback when post-install steps fail.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync()
+ {
+ // Arrange
+ var validatorMock = new Mock();
+ var instructionsMock = new Mock();
+ var loggerMock = new Mock();
+ var discovererMock = new Mock();
+ var resolverMock = new Mock();
+ var delivererMock = new Mock();
+
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" };
+ var validationResult = new ValidationResult(manifest.Id, new List());
+
+ validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(validationResult);
+
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error"));
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
+
+ // Act
+ var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Post-install step execution error", result.FirstError);
+ Assert.True(provider.RollbackCalled);
+ }
+
+ ///
+ /// Verifies that PrepareContentAsync triggers rollback when post-install steps are canceled.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync()
+ {
+ // Arrange
+ var validatorMock = new Mock();
+ var instructionsMock = new Mock();
+ var loggerMock = new Mock();
+ var discovererMock = new Mock();
+ var resolverMock = new Mock();
+ var delivererMock = new Mock();
+
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" };
+ var validationResult = new ValidationResult(manifest.Id, new List());
+
+ validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(validationResult);
+
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => provider.PrepareContentAsync(manifest, "/tmp/test"));
+
+ Assert.True(provider.RollbackCalled);
+ }
+
///
/// Verifies that PrepareContentAsync fails when manifest validation fails with errors.
///
@@ -60,6 +175,7 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync(
{
// Arrange
var validatorMock = new Mock();
+ var instructionsMock = new Mock();
var loggerMock = new Mock();
var discovererMock = new Mock();
var resolverMock = new Mock();
@@ -75,7 +191,13 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync(
validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
.ReturnsAsync(validationResult);
- var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object);
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
// Act
var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
@@ -94,13 +216,16 @@ private class TestContentProvider : BaseContentProvider
private readonly IContentResolver _resolver;
private readonly IContentDeliverer _deliverer;
+ public bool RollbackCalled { get; private set; }
+
public TestContentProvider(
IContentValidator validator,
+ IInstallationInstructionsService instructionsService,
ILogger logger,
IContentDiscoverer discoverer,
IContentResolver resolver,
IContentDeliverer deliverer)
- : base(validator, logger)
+ : base(validator, instructionsService, logger)
{
_discoverer = discoverer;
_resolver = resolver;
@@ -117,9 +242,19 @@ public TestContentProvider(
protected override IContentDeliverer Deliverer => _deliverer;
- public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default)
+ public override Task> GetValidatedContentAsync(
+ string contentId,
+ CancellationToken cancellationToken = default)
{
- var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" };
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create(contentId),
+ Name = "Test Content",
+ Version = "1.0.0",
+ ContentType = ContentType.Map,
+ TargetGame = GameType.Generals,
+ };
+
return Task.FromResult(OperationResult.CreateSuccess(manifest));
}
@@ -128,5 +263,15 @@ protected override Task> PrepareContentInternal
{
return Task.FromResult(OperationResult.CreateSuccess(manifest));
}
+
+ protected override Task RollbackPreparedContentAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ RollbackCalled = true;
+ return Task.CompletedTask;
+ }
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
index a50f687ff..8ecfe2931 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
@@ -46,12 +46,23 @@ public GitHubContentProviderTests()
_validatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
.ReturnsAsync(new ValidationResult("test", []));
+ var instructionsMock = new Mock();
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess());
+
_provider = new GitHubContentProvider(
[_discovererMock.Object],
[_resolverMock.Object],
[_delivererMock.Object],
_loggerMock.Object,
- _validatorMock.Object);
+ _validatorMock.Object,
+ instructionsMock.Object);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs
new file mode 100644
index 000000000..2eb524a35
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs
@@ -0,0 +1,1000 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Notifications;
+using GenHub.Core.Models.Common;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content;
+
+///
+/// Unit tests for .
+///
+public sealed class InstallationInstructionsServiceTests : IDisposable
+{
+ private readonly string _tempDirectory;
+ private readonly Mock _hashProviderMock;
+ private readonly Mock _notificationServiceMock;
+ private readonly Mock _userSettingsServiceMock;
+ private readonly UserSettings _userSettings;
+ private readonly InstallationInstructionsService _service;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public InstallationInstructionsServiceTests()
+ {
+ _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDirectory);
+
+ _hashProviderMock = new Mock();
+ _notificationServiceMock = new Mock();
+ _userSettingsServiceMock = new Mock();
+ _userSettings = new UserSettings();
+
+ _userSettingsServiceMock.Setup(u => u.Get()).Returns(_userSettings);
+ _userSettingsServiceMock.Setup(u => u.Update(It.IsAny>()))
+ .Callback>(action => action(_userSettings));
+
+ _service = new InstallationInstructionsService(
+ _hashProviderMock.Object,
+ _notificationServiceMock.Object,
+ _userSettingsServiceMock.Object,
+ NullLogger.Instance);
+ }
+
+ ///
+ /// Cleans up temporary resources after test execution.
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDirectory))
+ {
+ try
+ {
+ Directory.Delete(_tempDirectory, recursive: true);
+ }
+ catch
+ {
+ // Ignore cleanup error
+ }
+ }
+ }
+
+ ///
+ /// Verifies that executing post-install steps succeeds when no steps are declared.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions();
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory);
+
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Verifies that executing installer steps from an untrusted provider fails even if manifest metadata claims to be trusted.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Malicious Executable",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "malicious.exe",
+ },
+ ],
+ };
+
+ // Manifest claims GeneralsOnline, but providerSource is untrusted
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source");
+
+ Assert.False(result.Success);
+ Assert.Contains("not authorized to execute installation steps", result.FirstError);
+ }
+
+ ///
+ /// Verifies that mutating steps like RemoveFile and RenameFile fail and do not modify files on disk when provider is untrusted.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution()
+ {
+ var importantFilePath = Path.Combine(_tempDirectory, "important.dat");
+ var sourceFilePath = Path.Combine(_tempDirectory, "source.dat");
+ var destFilePath = Path.Combine(_tempDirectory, "dest.dat");
+
+ await File.WriteAllTextAsync(importantFilePath, "important content");
+ await File.WriteAllTextAsync(sourceFilePath, "source content");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Delete Something",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = "important.dat",
+ },
+ new InstallationStep
+ {
+ Name = "Rename Something",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "source.dat",
+ DestinationRelativePath = "dest.dat",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source");
+
+ Assert.False(result.Success);
+ Assert.Contains("not authorized to execute installation steps", result.FirstError);
+ Assert.True(File.Exists(importantFilePath));
+ Assert.True(File.Exists(sourceFilePath));
+ Assert.False(File.Exists(destFilePath));
+ }
+
+ ///
+ /// Verifies that paths attempting directory traversal are rejected.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Traverse Path",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = @"../../outside.exe",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that installer executables not declared in the manifest files list fail.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution()
+ {
+ var targetFile = "installer.exe";
+ var fullPath = Path.Combine(_tempDirectory, targetFile);
+ File.WriteAllText(fullPath, "binary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files = []; // Empty files list - installer not declared
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Undeclared Installer",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = targetFile,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("not declared in manifest files", result.FirstError);
+ }
+
+ ///
+ /// Verifies that hash mismatch during installer integrity check fails execution.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution()
+ {
+ var targetFile = "installer.exe";
+ var fullPath = Path.Combine(_tempDirectory, targetFile);
+ File.WriteAllText(fullPath, "binary content");
+
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync("actual_hash_value");
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = targetFile,
+ Hash = "expected_different_hash",
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Corrupted Installer",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = targetFile,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("Integrity verification failed", result.FirstError);
+ }
+
+ ///
+ /// Verifies that remove file steps successfully delete the target file.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile()
+ {
+ var fileToRemove = "temp_cache.tmp";
+ var fullPath = Path.Combine(_tempDirectory, fileToRemove);
+ File.WriteAllText(fullPath, "temporary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Remove Cache",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = fileToRemove,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(fullPath));
+ }
+
+ ///
+ /// Verifies that rename file steps successfully move target files.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile()
+ {
+ var sourceFile = "source.txt";
+ var destFile = Path.Combine("subfolder", "dest.txt");
+ var sourceFullPath = Path.Combine(_tempDirectory, sourceFile);
+ var destFullPath = Path.Combine(_tempDirectory, destFile);
+
+ File.WriteAllText(sourceFullPath, "hello world");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename File",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = sourceFile,
+ DestinationRelativePath = destFile,
+ StepKey = "test_rename_step",
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(sourceFullPath));
+ Assert.True(File.Exists(destFullPath));
+ Assert.Equal("hello world", File.ReadAllText(destFullPath));
+ Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step"));
+ }
+
+ ///
+ /// Verifies that verified installer execution runs and dispatches user notifications.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "test_installer.exe" : "test_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "test_installer_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [],
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey));
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(
+ GeneralsOnlineConstants.EacStepName,
+ GeneralsOnlineConstants.EacStatusMessage,
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ _notificationServiceMock.Verify(
+ n => n.ShowSuccess(
+ "Installation Step Completed",
+ It.Is(msg => msg.Contains(GeneralsOnlineConstants.EacStepName)),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that run-once steps already recorded in user settings are skipped.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution()
+ {
+ var scriptName = "installer.bat";
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile { RelativePath = scriptName },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ // Mark as already executed
+ _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey);
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+
+ // Notification should NOT be shown for skipped step
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that forcing execution re-runs run-once steps even if recorded in settings.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.exe" : "test_force_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "test_force_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [],
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ // Mark as already executed in settings
+ _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey);
+
+ // Force execution
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, force: true);
+
+ Assert.True(result.Success);
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(
+ GeneralsOnlineConstants.EacStepName,
+ GeneralsOnlineConstants.EacStatusMessage,
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that unknown installation step kinds return failure.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Unknown Step",
+ Kind = InstallationStepKind.Unknown,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("Unsupported installation step kind", result.FirstError);
+ }
+
+ ///
+ /// Verifies that elevated steps fail with an unsupported result on non-Windows platforms.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFailure()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var scriptName = "elevated_script.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ const string expectedHash = "elevated_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Elevated Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ RequiresElevation = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("requires administrator elevation, which is only supported on Windows", result.FirstError);
+ }
+
+ ///
+ /// Verifies that remove file steps reject paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RemoveFile_PathTraversalTarget_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Remove Escape",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = "../../outside.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that rename file steps reject source paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_SourcePathTraversal_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename Source Escape",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "../../outside.tmp",
+ DestinationRelativePath = "dest.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that rename file steps reject destination paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_DestinationPathTraversal_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename Destination Escape",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "source.tmp",
+ DestinationRelativePath = "../../outside.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that cancellation token terminates the running process and throws OperationCanceledException.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.exe" : "sleep_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nsleep 30\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "sleep_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Long Running Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [],
+ },
+ ],
+ };
+
+ using var cts = new CancellationTokenSource();
+ cts.CancelAfter(TimeSpan.FromMilliseconds(200));
+
+ await Assert.ThrowsAnyAsync(() =>
+ _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline,
+ cancellationToken: cts.Token));
+ }
+
+ ///
+ /// Verifies that when a precondition is fulfilled, execution is skipped and the step key is recorded.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_PreconditionFulfilled_SkipsExecutionAndRecordsStepKey()
+ {
+ var preconditionMock = new Mock();
+ preconditionMock.Setup(p => p.CanHandle(It.IsAny(), It.IsAny())).Returns(true);
+ preconditionMock.Setup(p => p.IsAlreadyFulfilled(It.IsAny(), It.IsAny())).Returns(true);
+
+ var serviceWithPrecondition = new InstallationInstructionsService(
+ _hashProviderMock.Object,
+ _notificationServiceMock.Object,
+ _userSettingsServiceMock.Object,
+ [preconditionMock.Object],
+ NullLogger.Instance);
+
+ const string stepKey = "test:precondition:step";
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Preconditioned Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "nonexistent.exe",
+ StepKey = stepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await serviceWithPrecondition.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.True(_userSettings.IsInstallationStepExecuted(stepKey));
+ }
+
+ ///
+ /// Verifies that verification fails when a step target file has no declared hash in the manifest.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NoDeclaredHash_FailsVerification()
+ {
+ var scriptName = "installer_nohash.exe";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+ File.WriteAllText(fullPath, "binary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = string.Empty,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "No Hash Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("has no declared hash", result.FirstError);
+ }
+
+ ///
+ /// Verifies that an installer process exiting with a non-zero exit code produces an execution failure.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "exit_error.cmd" : "exit_error.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ File.WriteAllText(fullPath, "exit /b 42\r\n");
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 42\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "exit_error_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Failing Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("failed with exit code", result.FirstError);
+ }
+
+ ///
+ /// Verifies that a successful RunOnce step persists its key immediately even if a subsequent step fails.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceStep_PersistsKeyImmediatelyEvenIfLaterStepFails()
+ {
+ var successFile = "success.tmp";
+ var fullPath = Path.Combine(_tempDirectory, successFile);
+ await File.WriteAllTextAsync(fullPath, "temporary");
+
+ const string step1Key = "step:runonce:first";
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Step 1 Remove",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = successFile,
+ StepKey = step1Key,
+ RunOnce = true,
+ },
+ new InstallationStep
+ {
+ Name = "Step 2 Unknown Kind",
+ Kind = InstallationStepKind.Unknown,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.False(File.Exists(fullPath));
+ Assert.True(_userSettings.IsInstallationStepExecuted(step1Key));
+ _userSettingsServiceMock.Verify(u => u.SaveAsync(It.IsAny()), Times.AtLeastOnce);
+ }
+
+ ///
+ /// Verifies that an already-executed RunOnce step is skipped without failing provider authorization.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceAlreadyExecuted_DoesNotFailAuthorizationForUntrustedProvider()
+ {
+ const string stepKey = "step:untrusted:runonce";
+ _userSettings.RecordInstallationStepExecuted(stepKey);
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Already Executed Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "installer.exe",
+ StepKey = stepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: "untrusted_source");
+
+ Assert.True(result.Success);
+ }
+
+ private static ContentManifest CreateBaseManifest() => new()
+ {
+ Id = "1.0.test.gameclient.variant",
+ Name = "Test Manifest",
+ Version = "1.0.0",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ };
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs
new file mode 100644
index 000000000..7f1913983
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using GenHub.Core.Constants;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services.GeneralsOnline;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline;
+
+///
+/// Unit tests for .
+///
+public sealed class EasyAntiCheatPreconditionTests
+{
+ private readonly EasyAntiCheatPrecondition _precondition = new(NullLogger.Instance);
+
+ ///
+ /// Verifies that CanHandle returns false when step or manifest is null.
+ ///
+ [Fact]
+ public void CanHandle_NullStepOrManifest_ReturnsFalse()
+ {
+ var manifest = CreateBaseManifest();
+ var step = CreateEacStep();
+
+ Assert.False(_precondition.CanHandle(null!, manifest));
+ Assert.False(_precondition.CanHandle(step, null!));
+ }
+
+ ///
+ /// Verifies that CanHandle returns false when step kind is not RunVerifiedInstaller.
+ ///
+ [Fact]
+ public void CanHandle_NonInstallerKind_ReturnsFalse()
+ {
+ var manifest = CreateBaseManifest();
+ var step = new InstallationStep
+ {
+ Name = "Remove File Step",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ };
+
+ Assert.False(_precondition.CanHandle(step, manifest));
+ }
+
+ ///
+ /// Verifies that CanHandle returns false when publisher type is not GeneralsOnline.
+ ///
+ [Fact]
+ public void CanHandle_NonGeneralsOnlinePublisher_ReturnsFalse()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ PublisherType = "OtherPublisher",
+ };
+
+ var step = CreateEacStep();
+
+ Assert.False(_precondition.CanHandle(step, manifest));
+ }
+
+ ///
+ /// Verifies that CanHandle returns false when executable name does not match EAC setup executable.
+ ///
+ [Fact]
+ public void CanHandle_NonEacExecutable_ReturnsFalse()
+ {
+ var manifest = CreateBaseManifest();
+ var step = new InstallationStep
+ {
+ Name = "Other Executable",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "other_installer.exe",
+ };
+
+ Assert.False(_precondition.CanHandle(step, manifest));
+ }
+
+ ///
+ /// Verifies that IsAlreadyFulfilled returns false on non-Windows platforms.
+ ///
+ [Fact]
+ public void IsAlreadyFulfilled_NonWindows_ReturnsFalse()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var manifest = CreateBaseManifest();
+ var step = CreateEacStep();
+
+ Assert.False(_precondition.IsAlreadyFulfilled(step, manifest));
+ }
+
+ ///
+ /// Verifies that CanHandle behavior matches operating system requirements.
+ ///
+ [Fact]
+ public void CanHandle_ValidStep_MatchesOperatingSystem()
+ {
+ var manifest = CreateBaseManifest();
+ var step = CreateEacStep();
+
+ var result = _precondition.CanHandle(step, manifest);
+ Assert.Equal(OperatingSystem.IsWindows(), result);
+ }
+
+ private static ContentManifest CreateBaseManifest() => new()
+ {
+ Id = "1.0.test.gameclient.variant",
+ Name = "Generals Online",
+ Version = "1.0.0",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ },
+ };
+
+ private static InstallationStep CreateEacStep() => new()
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ Arguments = ["install", GeneralsOnlineConstants.EacProductId],
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ };
+}
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
index 4e5d7c096..ec5ff6a19 100644
--- 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
@@ -561,6 +561,74 @@ await Assert.ThrowsAsync(
Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted")));
}
+ ///
+ /// Verifies that DeliverContentAsync passes the declared expected hash to IDownloadService.DownloadFileAsync.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DeliverContentAsync_WithDeclaredHash_PassesExpectedHashToDownloadServiceAsync()
+ {
+ // Arrange
+ const string expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+ var zipPath = Path.Combine(_tempDir, "test_hash.zip");
+ CreateTestZip(zipPath);
+
+ string? capturedExpectedHash = null;
+ _downloadServiceMock
+ .Setup(d => d.DownloadFileAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .Callback?, CancellationToken>((url, path, hash, prog, token) =>
+ {
+ capturedExpectedHash = hash;
+ File.Copy(zipPath, path, true);
+ })
+ .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1)));
+
+ _manifestPoolMock
+ .Setup(p => p.AddManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(true));
+
+ 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,
+ Hash = expectedHash,
+ },
+ ],
+ InstallationInstructions = new InstallationInstructions
+ {
+ DownloadHash = expectedHash,
+ },
+ };
+
+ var targetDir = Path.Combine(_tempDir, "hash_delivery");
+ Directory.CreateDirectory(targetDir);
+
+ // Act
+ var result = await _deliverer.DeliverContentAsync(manifest, targetDir);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Equal(expectedHash, capturedExpectedHash);
+ }
+
private static void CreateTestZip(string zipPath)
{
using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create);
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs
index a016b37fc..db6aaaff6 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs
@@ -1,5 +1,6 @@
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Providers;
+using GenHub.Core.Models.GeneralsOnline;
using GenHub.Core.Models.Providers;
using GenHub.Features.Content.Services.GeneralsOnline;
using Microsoft.Extensions.Logging.Abstractions;
@@ -91,4 +92,34 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectlyAsync()
var item = result.Data.First();
Assert.Equal("111825_QFE2", item.Version);
}
+
+ ///
+ /// Tests that ParseAsync correctly populates the SHA256 hash when present in the API response.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task ParseAsync_WithSha256_PopulatesSha256OnReleaseAsync()
+ {
+ // Arrange
+ const string expectedSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+ var json = $@"{{
+ ""version"": ""111825_QFE2"",
+ ""download_url"": ""https://example.com/download.zip"",
+ ""size"": 123456,
+ ""sha256"": ""{expectedSha256}"",
+ ""release_notes"": ""Fixes stuff""
+ }}";
+
+ var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}";
+
+ // Act
+ var result = await _parser.ParseAsync(wrapper, _provider);
+
+ // Assert
+ Assert.True(result.Success);
+ var item = result.Data.First();
+ var release = item.GetData();
+ Assert.NotNull(release);
+ Assert.Equal(expectedSha256, release.Sha256);
+ }
}
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
index 04e9b80a9..443824a51 100644
--- 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
@@ -129,6 +129,115 @@ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSix
ignoreCase: true);
}
+ ///
+ /// Verifies that EAC portable layout configures a post-install step to run the verified EAC setup executable.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task CreateManifestsFromExtractedContentAsync_EacLayout_ConfiguresEacPostInstallStepAsync()
+ {
+ WriteEacPortableLayout();
+
+ var gameClient = await CreateGameClientManifestAsync();
+
+ Assert.NotNull(gameClient.InstallationInstructions);
+ var postSteps = gameClient.InstallationInstructions.PostInstallSteps;
+ var eacStep = Assert.Single(postSteps);
+
+ Assert.Equal(GeneralsOnlineConstants.EacStepName, eacStep.Name);
+ Assert.Equal(InstallationStepKind.RunVerifiedInstaller, eacStep.Kind);
+ Assert.Equal(GameClientConstants.GeneralsOnlineEacSetupExecutable, eacStep.TargetRelativePath);
+ Assert.True(eacStep.RequiresElevation);
+ Assert.True(eacStep.RunOnce);
+ Assert.Equal(GeneralsOnlineConstants.EacStepKey, eacStep.StepKey);
+ Assert.Equal(GeneralsOnlineConstants.EacStatusMessage, eacStep.StatusMessage);
+ Assert.NotNull(eacStep.Arguments);
+ Assert.Equal(
+ [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId],
+ eacStep.Arguments);
+ }
+
+ ///
+ /// Verifies that Pre-EAC portable layout does not configure an EAC post-install step when setup executable is absent.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_DoesNotConfigureEacPostInstallStepAsync()
+ {
+ WriteFile(GameClientConstants.GeneralsOnline60HzExecutable);
+ WriteFile("libcurl.dll");
+
+ var gameClient = await CreateGameClientManifestAsync();
+
+ Assert.NotNull(gameClient.InstallationInstructions);
+ var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s =>
+ string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase));
+ Assert.Null(eacStep);
+ }
+
+ ///
+ /// Verifies that an inherited EAC step is not duplicated when EAC portable layout already contains the setup executable.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutablePresent_DoesNotDuplicateEacStepAsync()
+ {
+ WriteEacPortableLayout();
+
+ var originalManifest = CreateOriginalManifest();
+ originalManifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ },
+ ],
+ };
+
+ var gameClient = await CreateGameClientManifestAsync(originalManifest);
+
+ Assert.NotNull(gameClient.InstallationInstructions);
+ var eacSteps = gameClient.InstallationInstructions.PostInstallSteps.Where(s =>
+ string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)).ToList();
+ Assert.Single(eacSteps);
+ }
+
+ ///
+ /// Verifies that an inherited EAC step is dropped when the setup executable is absent in extracted content.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutableAbsent_DropsEacStepAsync()
+ {
+ WriteFile(GameClientConstants.GeneralsOnline60HzExecutable);
+ WriteFile("libcurl.dll");
+
+ var originalManifest = CreateOriginalManifest();
+ originalManifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ },
+ ],
+ };
+
+ var gameClient = await CreateGameClientManifestAsync(originalManifest);
+
+ Assert.NotNull(gameClient.InstallationInstructions);
+ var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s =>
+ string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase));
+ Assert.Null(eacStep);
+ }
+
///
public void Dispose()
{
@@ -170,7 +279,7 @@ private void WriteFile(string relativePath)
File.WriteAllText(fullPath, relativePath);
}
- private async Task CreateGameClientManifestAsync()
+ private async Task CreateGameClientManifestAsync(ContentManifest? originalManifest = null)
{
var providerLoader = new Mock();
var factory = new GeneralsOnlineManifestFactory(
@@ -178,7 +287,7 @@ private async Task CreateGameClientManifestAsync()
providerLoader.Object);
var manifests = await factory.CreateManifestsFromExtractedContentAsync(
- CreateOriginalManifest(),
+ originalManifest ?? CreateOriginalManifest(),
_extractedDirectory);
return manifests.Single(manifest => manifest.ContentType == ContentType.GameClient);
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
index 68a1e4434..200f82338 100644
--- 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
@@ -401,4 +401,33 @@ public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependen
var resolvedClientDep = resolvedDeps.First(d => d.DependencyType == ContentType.GameClient);
Assert.Equal(expectedClientId.Value, resolvedClientDep.Id.Value);
}
+
+ ///
+ /// Verifies that CreateManifests propagates Sha256 to file hash and installation instructions download hash.
+ ///
+ [Fact]
+ public void CreateManifests_WithSha256_SetsFileHashAndDownloadHash()
+ {
+ // Arrange
+ const string expectedHash = "abc123hash";
+ var release = new GeneralsOnlineRelease
+ {
+ Version = "101525_QFE5",
+ ReleaseDate = DateTime.UtcNow,
+ PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip",
+ PortableSize = 1048576,
+ Sha256 = expectedHash,
+ Changelog = "https://example.com/changelog",
+ };
+
+ // Act
+ var manifests = _factory.CreateManifests(release);
+
+ // Assert
+ var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient);
+ Assert.NotNull(gameClient);
+ Assert.Equal(expectedHash, gameClient.InstallationInstructions?.DownloadHash);
+ var zipFile = Assert.Single(gameClient.Files);
+ Assert.Equal(expectedHash, zipFile.Hash);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
index 2b645c6e5..ace7c1ac6 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
@@ -30,6 +30,7 @@ public class SuperHackersProviderTests
private readonly Mock _resolverMock;
private readonly Mock _delivererMock;
private readonly Mock _validatorMock;
+ private readonly Mock _instructionsServiceMock;
private readonly SuperHackersProvider _provider;
///
@@ -42,6 +43,7 @@ public SuperHackersProviderTests()
_resolverMock = new Mock();
_delivererMock = new Mock();
_validatorMock = new Mock();
+ _instructionsServiceMock = new Mock();
_resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId);
_delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer);
@@ -49,13 +51,23 @@ public SuperHackersProviderTests()
_validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync(new ValidationResult("test", []));
+ _instructionsServiceMock.Setup(s => s.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess());
+
_provider = new SuperHackersProvider(
_providerDefinitionLoaderMock.Object,
_gitHubApiClientMock.Object,
[_resolverMock.Object],
[_delivererMock.Object],
_validatorMock.Object,
- NullLogger.Instance);
+ NullLogger.Instance,
+ _instructionsServiceMock.Object);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
index 92737c49d..453bf4be1 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs
@@ -337,7 +337,8 @@ private static SuperHackersProvider CreateSuperHackersProvider()
[resolverMock.Object],
[delivererMock.Object],
new Mock().Object,
- NullLogger.Instance);
+ NullLogger.Instance,
+ new Mock().Object);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs
index aad8be697..eafa0df4c 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs
@@ -208,6 +208,60 @@ public void WithInstallationInstructions_SetsWorkspaceStrategy()
Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy);
}
+ ///
+ /// Tests that WithInstallationInstructions sets the full installation instructions object.
+ ///
+ [Fact]
+ public void WithInstallationInstructions_SetsCompleteObject()
+ {
+ var instructions = new InstallationInstructions
+ {
+ WorkspaceStrategy = WorkspaceStrategy.FullCopy,
+ DownloadHash = "abc123hash",
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Step 1",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "setup.exe",
+ },
+ ],
+ };
+
+ var result = _builder
+ .WithBasicInfo("Test Publisher", "Test Name", "1")
+ .WithInstallationInstructions(instructions)
+ .Build();
+
+ Assert.NotNull(result.InstallationInstructions);
+ Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy);
+ Assert.Equal("abc123hash", result.InstallationInstructions.DownloadHash);
+ Assert.Single(result.InstallationInstructions.PostInstallSteps);
+ Assert.Equal("Step 1", result.InstallationInstructions.PostInstallSteps[0].Name);
+ }
+
+ ///
+ /// Tests that AddPostInstallStep adds a structured installation step.
+ ///
+ [Fact]
+ public void AddPostInstallStep_AddsStepCorrectly()
+ {
+ var result = _builder
+ .WithBasicInfo("Test Publisher", "Test Name", "1")
+ .AddPostInstallStep("EAC Setup", InstallationStepKind.RunVerifiedInstaller, "EasyAntiCheat_EOS_Setup.exe", ["install", "12345"], requiresElevation: true, statusMessage: "Installing AntiCheat")
+ .Build();
+
+ Assert.NotNull(result.InstallationInstructions);
+ var step = Assert.Single(result.InstallationInstructions.PostInstallSteps);
+ Assert.Equal("EAC Setup", step.Name);
+ Assert.Equal(InstallationStepKind.RunVerifiedInstaller, step.Kind);
+ Assert.Equal("EasyAntiCheat_EOS_Setup.exe", step.TargetRelativePath);
+ Assert.True(step.RequiresElevation);
+ Assert.Equal("Installing AntiCheat", step.StatusMessage);
+ Assert.Equal(["install", "12345"], step.Arguments);
+ }
+
///
/// Tests that Build returns a valid manifest with minimal configuration.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs
index 41e389ba1..9bdd35e8b 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs
@@ -114,6 +114,40 @@ public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink()
}
}
+ ///
+ /// Rejects a candidate that leaves the base directory through an intermediate symbolic link
+ /// when the target file on the outside destination already exists on disk.
+ ///
+ [Fact]
+ public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink_WhenOutsideTargetFileExists()
+ {
+ var root = CreateWorkingDirectory();
+
+ try
+ {
+ var baseDirectory = Path.Combine(root, "extract");
+ var outside = Path.Combine(root, "outside");
+ Directory.CreateDirectory(baseDirectory);
+ Directory.CreateDirectory(outside);
+
+ var outsideFile = Path.Combine(outside, "installer.exe");
+ File.WriteAllText(outsideFile, "payload");
+
+ if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside))
+ {
+ return;
+ }
+
+ var candidate = Path.Combine(baseDirectory, "link", "installer.exe");
+
+ Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
///
/// Accepts a candidate beneath a symbolic link that stays inside the base directory, so
/// following links tightens the check without refusing content a link merely reorganizes.
@@ -144,6 +178,51 @@ public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysIn
}
}
+ ///
+ /// Rejects a candidate that is a direct file symbolic link pointing to a file outside the base directory.
+ ///
+ [Fact]
+ public void IsPathWithinDirectory_RejectsCandidateThatIsDirectFileSymbolicLink_PointingOutside()
+ {
+ var root = CreateWorkingDirectory();
+
+ try
+ {
+ var baseDirectory = Path.Combine(root, "extract");
+ var outside = Path.Combine(root, "outside");
+ Directory.CreateDirectory(baseDirectory);
+ Directory.CreateDirectory(outside);
+
+ var outsideFile = Path.Combine(outside, "secret.dat");
+ File.WriteAllText(outsideFile, "secret");
+
+ var linkFile = Path.Combine(baseDirectory, "link_file.dat");
+ if (!TryCreateFileSymbolicLink(linkFile, outsideFile))
+ {
+ return;
+ }
+
+ Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, linkFile));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ ///
+ /// Verifies that NormalizeRelativePath standardizes path separators.
+ ///
+ [Fact]
+ public void NormalizeRelativePath_StandardizesSeparators()
+ {
+ var input = @"folder\subfolder/file.exe";
+ var normalized = PathHelper.NormalizeRelativePath(input);
+
+ var expected = Path.Combine("folder", "subfolder", "file.exe");
+ Assert.Equal(expected, normalized);
+ }
+
private static string CreateWorkingDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "GenHubContainmentLinks", Guid.NewGuid().ToString("N"));
@@ -169,4 +248,26 @@ private static bool TryCreateDirectorySymbolicLink(string linkPath, string targe
return false;
}
}
+
+ private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath)
+ {
+ try
+ {
+ File.CreateSymbolicLink(linkPath, targetPath);
+
+ return true;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs
index 3ed1922ae..79ae5fcde 100644
--- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs
@@ -25,6 +25,7 @@ namespace GenHub.Features.Content.Services.CommunityOutpost;
/// Available content resolvers.
/// Available content deliverers.
/// The content validator.
+/// The installation instructions service.
/// The logger.
public class CommunityOutpostProvider(
IProviderDefinitionLoader providerDefinitionLoader,
@@ -32,11 +33,10 @@ public class CommunityOutpostProvider(
IEnumerable resolvers,
IEnumerable deliverers,
IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService,
ILogger logger)
- : BaseContentProvider(contentValidator, logger)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
- private readonly IProviderDefinitionLoader _providerDefinitionLoader = providerDefinitionLoader;
-
private readonly IContentDiscoverer _discoverer = discoverers.FirstOrDefault(d =>
d.SourceName.Contains(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("No Community Outpost discoverer found");
@@ -127,7 +127,7 @@ public override async Task> GetValidatedContent
}
// Try to get from the loader (it should already be loaded at startup)
- _cachedProviderDefinition = _providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId);
+ _cachedProviderDefinition = providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId);
if (_cachedProviderDefinition == null)
{
diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs
index 42ee8b423..73ac811e1 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs
@@ -122,7 +122,8 @@ public async Task> DeliverContentAsync(
packageManifest.Publisher?.Name ?? string.Empty,
packageManifest.Publisher?.Website ?? string.Empty,
packageManifest.Publisher?.SupportUrl ?? string.Empty,
- packageManifest.Publisher?.ContactEmail ?? string.Empty)
+ packageManifest.Publisher?.ContactEmail ?? string.Empty,
+ packageManifest.Publisher?.PublisherType ?? string.Empty)
.WithMetadata(
packageManifest.Metadata?.Description ?? string.Empty,
packageManifest.Metadata?.Tags,
@@ -173,7 +174,7 @@ await manifestBuilder.AddContentAddressableFileAsync(
// Add installation instructions if present
if (packageManifest.InstallationInstructions != null)
{
- manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy);
+ manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions);
}
var deliveredManifest = manifestBuilder.Build();
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs
index dea3d0047..e071eb54b 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs
@@ -21,7 +21,9 @@ public class AODMapsContentProvider(
IEnumerable resolvers,
IEnumerable deliverers,
ILogger logger,
- IContentValidator contentValidator) : BaseContentProvider(contentValidator, logger)
+ IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
private readonly IContentDiscoverer _aodMapsDiscoverer = discoverers.FirstOrDefault(d =>
string.Equals(d.SourceName, AODMapsConstants.DiscovererSourceName, StringComparison.OrdinalIgnoreCase))
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs
index 0262f9655..be26d3495 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs
@@ -18,13 +18,27 @@ namespace GenHub.Features.Content.Services.ContentProviders;
///
/// Base class for content providers with common pipeline orchestration logic.
///
-public abstract class BaseContentProvider(
- IContentValidator contentValidator,
- ILogger logger
-) : IContentProvider
+public abstract class BaseContentProvider : IContentProvider
{
- private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger));
- private readonly IContentValidator _contentValidator = contentValidator ?? throw new ArgumentNullException(nameof(contentValidator));
+ private readonly IContentValidator _contentValidator;
+ private readonly IInstallationInstructionsService _installationInstructionsService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The content validator.
+ /// The installation instructions service.
+ /// The logger.
+ protected BaseContentProvider(
+ IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService,
+ ILogger logger)
+ {
+ _contentValidator = contentValidator;
+ _installationInstructionsService = installationInstructionsService;
+ _logger = logger;
+ }
///
public abstract string SourceName { get; }
@@ -89,7 +103,7 @@ public virtual async Task>> Sea
Logger.LogWarning(
"Resolution failed for {ContentName}: {Error}",
discovered.Name,
- resolutionResult.FirstError ?? "Unknown error");
+ resolutionResult.FirstError);
}
}
else
@@ -101,12 +115,7 @@ public virtual async Task>> Sea
return OperationResult>.CreateSuccess(resolvedResults);
}
- ///
- /// Gets the manifest for the specified content ID.
- ///
- /// The content identifier.
- /// A token to cancel the operation.
- /// A result containing the game manifest.
+ ///
public abstract Task> GetValidatedContentAsync(
string contentId,
CancellationToken cancellationToken = default);
@@ -149,50 +158,99 @@ public virtual async Task> PrepareContentAsync(
// Delegate to implementation-specific preparation
var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken);
- if (result.Success)
+ if (!result.Success)
{
- // Final validation of prepared content
- progress?.Report(new ContentAcquisitionProgress
- {
- Phase = ContentAcquisitionPhase.ValidatingFiles,
- CurrentOperation = "Validating prepared content...",
- });
+ return result;
+ }
- // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress
- IProgress? validationProgress = null;
- if (progress != null)
- {
- validationProgress = new Progress(vp =>
- {
- // Map validation progress to content acquisition progress for UI display
- progress.Report(new ContentAcquisitionProgress
- {
- Phase = ContentAcquisitionPhase.ValidatingFiles,
- ProgressPercentage = vp.PercentComplete,
- CurrentOperation = vp.CurrentFile ?? "Validating files",
- FilesProcessed = vp.Processed,
- TotalFiles = vp.Total,
- });
- });
- }
+ if (result.Data == null)
+ {
+ Logger.LogError("Content preparation returned success without manifest data for {ManifestId}", manifest.Id);
+ return OperationResult.CreateFailure($"Content preparation returned no manifest data for {manifest.Id}.");
+ }
- var fullResult = await ContentValidator.ValidateAllAsync(
+ try
+ {
+ // Execute post-installation steps if declared on the delivered manifest
+ var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync(
+ result.Data,
workingDirectory,
- result.Data!,
- validationProgress,
+ providerSource: SourceName,
+ progress: progress,
cancellationToken: cancellationToken);
- if (!fullResult.IsValid)
+ if (!stepExecutionResult.Success)
{
- // Log as warning only - content may have been moved to CAS already
- // CAS storage validates content hash on store, so this is informational
- Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id);
- foreach (var issue in fullResult.Issues.Take(5))
- {
- Logger.LogDebug("Validation issue: {Message}", issue.Message);
- }
+ Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError);
+ await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory);
+ return OperationResult.CreateFailure(stepExecutionResult.Errors);
}
}
+ catch (OperationCanceledException)
+ {
+ Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id);
+ await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id);
+ await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory);
+ return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}");
+ }
+
+ // Final validation of prepared content
+ progress?.Report(new ContentAcquisitionProgress
+ {
+ Phase = ContentAcquisitionPhase.ValidatingFiles,
+ CurrentOperation = "Validating prepared content...",
+ });
+
+ // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress
+ IProgress? validationProgress = null;
+ if (progress != null)
+ {
+ validationProgress = new Progress(vp =>
+ {
+ // Map validation progress to content acquisition progress for UI display
+ progress.Report(new ContentAcquisitionProgress
+ {
+ Phase = ContentAcquisitionPhase.ValidatingFiles,
+ ProgressPercentage = vp.PercentComplete,
+ CurrentOperation = vp.CurrentFile ?? "Validating files",
+ FilesProcessed = vp.Processed,
+ TotalFiles = vp.Total,
+ });
+ });
+ }
+
+ var fullResult = await ContentValidator.ValidateAllAsync(
+ workingDirectory,
+ result.Data,
+ validationProgress,
+ cancellationToken: cancellationToken);
+
+ if (!fullResult.IsValid)
+ {
+ Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id);
+ }
+
+ try
+ {
+ await OnContentPreparationCompletedAsync(manifest, result.Data, workingDirectory, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ Logger.LogInformation("Content preparation completion hook was canceled for manifest {ManifestId}; rolling back", manifest.Id);
+ await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Content preparation completion hook failed for manifest {ManifestId}; rolling back", manifest.Id);
+ await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory);
+ return OperationResult.CreateFailure($"Content preparation completion hook failed: {ex.Message}");
+ }
return result;
}
@@ -208,16 +266,55 @@ public virtual async Task> PrepareContentAsync(
}
}
+ ///
+ /// Rolls back prepared content and registered manifests when post-preparation steps fail.
+ ///
+ /// The original requested manifest.
+ /// The prepared manifest returned by PrepareContentInternalAsync.
+ /// The working directory where content was prepared.
+ /// A token to cancel rollback operations.
+ /// A task representing the asynchronous operation.
+ protected virtual Task RollbackPreparedContentAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Executes cleanup or finalization when content preparation and validation succeed.
+ ///
+ /// The original requested manifest.
+ /// The prepared manifest returned by PrepareContentInternalAsync.
+ /// The working directory where content was prepared.
+ /// A token to cancel finalization operations.
+ /// A task representing the asynchronous operation.
+ protected virtual Task OnContentPreparationCompletedAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
///
/// Gets the logger for this provider.
///
- protected ILogger Logger => logger;
+ protected ILogger Logger => _logger;
///
/// Gets the content validator for manifest validation.
///
protected IContentValidator ContentValidator => _contentValidator;
+ ///
+ /// Gets the installation instructions service for post-install execution.
+ ///
+ protected IInstallationInstructionsService? InstallationInstructionsService => _installationInstructionsService;
+
///
/// Gets the discoverer for this provider.
///
@@ -315,4 +412,19 @@ private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult disco
resolved.SetData(manifest);
return resolved;
}
+
+ private async Task SafeRollbackPreparedContentAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory)
+ {
+ try
+ {
+ await RollbackPreparedContentAsync(originalManifest, preparedManifest, workingDirectory, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Rollback failed during error recovery for manifest {ManifestId}", originalManifest.Id);
+ }
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs
index 4c166bb66..8017522e7 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs
@@ -21,8 +21,9 @@ public class CNCLabsContentProvider(
IEnumerable resolvers,
IEnumerable deliverers,
ILogger logger,
- IContentValidator contentValidator)
- : BaseContentProvider(contentValidator, logger)
+ IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
private readonly IContentDiscoverer _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true)
?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers));
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs
index 16300c566..cbbac85a5 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs
@@ -24,8 +24,9 @@ public class LocalFileSystemContentProvider(
IEnumerable deliverers,
ILogger logger,
IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService,
IConfigurationProviderService configurationProvider)
- : BaseContentProvider(contentValidator, logger)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
private readonly IContentDiscoverer _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true)
?? throw new InvalidOperationException("No FileSystem discoverer found");
diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs
index 8595f0d79..089ff48d6 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs
@@ -21,8 +21,9 @@ public class ModDBContentProvider(
IEnumerable resolvers,
IEnumerable deliverers,
ILogger logger,
- IContentValidator contentValidator)
- : BaseContentProvider(contentValidator, logger)
+ IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
private readonly IContentDiscoverer _moddbDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase) == true)
?? throw new ArgumentException("ModDB discoverer not found", nameof(discoverers));
diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs
new file mode 100644
index 000000000..f538f8c05
--- /dev/null
+++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs
@@ -0,0 +1,106 @@
+using System;
+using System.IO;
+using System.Runtime.Versioning;
+using System.Security;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using Microsoft.Extensions.Logging;
+using Microsoft.Win32;
+
+namespace GenHub.Features.Content.Services.GeneralsOnline;
+
+///
+/// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry.
+///
+/// Optional logger instance for diagnostics.
+public class EasyAntiCheatPrecondition(ILogger? logger = null) : IInstallationStepPrecondition
+{
+ ///
+ public bool CanHandle(InstallationStep step, ContentManifest manifest)
+ {
+ if (!OperatingSystem.IsWindows() || step == null || manifest == null)
+ {
+ return false;
+ }
+
+ if (step.Kind != InstallationStepKind.RunVerifiedInstaller)
+ {
+ return false;
+ }
+
+ var isGeneralsOnline = string.Equals(
+ manifest.Publisher?.PublisherType,
+ PublisherTypeConstants.GeneralsOnline,
+ StringComparison.OrdinalIgnoreCase);
+
+ if (!isGeneralsOnline)
+ {
+ return false;
+ }
+
+ var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty);
+ return string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ return IsProductRegisteredOnWindows(step);
+ }
+
+ [SupportedOSPlatform("windows")]
+ private bool IsProductRegisteredOnWindows(InstallationStep step)
+ {
+ try
+ {
+ var productId = (step.Arguments is { Count: > 1 } && !string.IsNullOrWhiteSpace(step.Arguments[1]))
+ ? step.Arguments[1]
+ : GeneralsOnlineConstants.EacProductId;
+
+ if (string.IsNullOrWhiteSpace(productId))
+ {
+ return false;
+ }
+
+ var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}";
+
+ using var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
+ using var key32 = baseKey32.OpenSubKey(subKeyPath);
+ if (key32 != null)
+ {
+ return true;
+ }
+
+ using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
+ using var key64 = baseKey64.OpenSubKey(subKeyPath);
+ if (key64 != null)
+ {
+ return true;
+ }
+ }
+ catch (SecurityException ex)
+ {
+ logger?.LogWarning(ex, "Insufficient permissions to inspect Easy Anti-Cheat registry keys for step '{StepName}'", step.Name);
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger?.LogWarning(ex, "Access denied when inspecting Easy Anti-Cheat registry keys for step '{StepName}'", step.Name);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ logger?.LogDebug(ex, "Error while checking Easy Anti-Cheat registry registration for step '{StepName}'", step.Name);
+ return false;
+ }
+
+ return false;
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs
index 53e9cb95e..c04d78760 100644
--- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs
+++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs
@@ -272,11 +272,20 @@ private static void CleanupTempArtifacts(string? zipPath, string? extractPath, I
CurrentFile = zipFile.RelativePath,
});
- logger.LogDebug("Downloading ZIP from {Url} to {Path}", zipFile.DownloadUrl, zipPath);
+ var expectedHash = !string.IsNullOrWhiteSpace(zipFile.Hash)
+ ? zipFile.Hash
+ : packageManifest.InstallationInstructions?.DownloadHash;
+
+ if (string.IsNullOrWhiteSpace(expectedHash))
+ {
+ expectedHash = null;
+ }
+
+ logger.LogDebug("Downloading ZIP from {Url} to {Path} (expected hash: {Hash})", zipFile.DownloadUrl, zipPath, expectedHash);
var downloadResult = await downloadService.DownloadFileAsync(
new Uri(zipFile.DownloadUrl!),
zipPath,
- expectedHash: null,
+ expectedHash: expectedHash,
progress: null,
cancellationToken);
diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs
index d2ab6ed83..91f2ed956 100644
--- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs
+++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs
@@ -148,6 +148,7 @@ private static GeneralsOnlineRelease CreateReleaseFromApiResponse(GeneralsOnline
ReleaseDate = versionDate,
PortableUrl = apiResponse.DownloadUrl,
PortableSize = apiResponse.Size,
+ Sha256 = apiResponse.Sha256,
Changelog = apiResponse.ReleaseNotes ?? $"Generals Online {apiResponse.Version}",
};
}
diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs
index 59a2eb756..712b76997 100644
--- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs
+++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs
@@ -25,6 +25,21 @@ public class GeneralsOnlineManifestFactory(
ILogger logger,
IProviderDefinitionLoader providerLoader) : IPublisherManifestFactory
{
+ ///
+ /// File info extracted from archive for manifest generation.
+ ///
+ /// The relative path within archive.
+ /// The file info.
+ /// The SHA-256 hash.
+ /// Whether this is a map file.
+ /// Whether this is a game data file.
+ private readonly record struct ExtractedFileInfo(
+ string RelativePath,
+ FileInfo FileInfo,
+ string Hash,
+ bool IsMap,
+ bool IsGameData);
+
///
public string PublisherId => PublisherTypeConstants.GeneralsOnline;
@@ -96,10 +111,29 @@ public ContentManifest CreateVariantManifest(
DownloadUrl = release.PortableUrl,
Size = release.PortableSize ?? 0, // Use 0 when size is unknown
SourceType = ContentSourceType.RemoteDownload,
- Hash = string.Empty,
+ Hash = release.Sha256 ?? string.Empty,
},
],
Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion),
+ InstallationInstructions = new InstallationInstructions
+ {
+ WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy,
+ DownloadHash = release.Sha256,
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId],
+ RequiresElevation = true,
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ },
};
}
@@ -323,6 +357,7 @@ private ContentManifest CreateGameDataPatchManifest(GeneralsOnlineRelease releas
// Files will be populated during extraction
Files = [],
Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion),
+ InstallationInstructions = new InstallationInstructions(),
};
}
@@ -378,18 +413,19 @@ private ContentManifest CreateQuickMatchMapPackManifest(GeneralsOnlineRelease re
// MapPack requires Zero Hour installation
GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(),
],
+ InstallationInstructions = new InstallationInstructions(),
};
}
///
- /// Creates all variant manifests (60Hz, MapPack, and GameData Patch) from the original manifest.
- /// This is called AFTER extraction - we use the original manifest's metadata to create variants.
+ /// Creates variant manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) from an original manifest.
+ /// This is used after downloading and extracting the portable ZIP.
///
- /// The manifest from the Resolver (contains version, publisher info, etc.).
- /// List of variant manifests ready for file hash population.
+ /// The original manifest (can be 60Hz or generic).
+ /// List of variant manifests with basic information populated.
private List CreateVariantManifestsFromOriginal(ContentManifest originalManifest)
{
- var manifests = new List();
+ List manifests = [];
var version = originalManifest.Version ?? GeneralsOnlineConstants.UnknownVersion;
var userVersion = ParseVersionForManifestId(version);
@@ -440,6 +476,10 @@ private List CreateVariantManifestsFromOriginal(ContentManifest
},
Files = [],
Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion),
+ InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions
+ {
+ WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy,
+ },
});
// Create QuickMatch MapPack
@@ -469,6 +509,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest
[
GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(),
],
+ InstallationInstructions = new InstallationInstructions(),
});
// Create GeneralsOnlineGameData data patch
@@ -495,6 +536,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest
},
Files = [],
Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion),
+ InstallationInstructions = new InstallationInstructions(),
});
return manifests;
@@ -520,10 +562,63 @@ private async Task> UpdateManifestsWithExtractedFiles(
cancellationToken.ThrowIfCancellationRequested();
+ var filesWithHashes = await ScanExtractedFilesAsync(extractPath, cancellationToken);
+ var updatedManifests = new List();
+
+ foreach (var manifest in manifests)
+ {
+ var manifestFiles = BuildManifestFilesForManifest(manifest, filesWithHashes);
+
+ if (manifestFiles.Count == 0)
+ {
+ if (manifest.ContentType is ContentType.MapPack or ContentType.Patch)
+ {
+ logger.LogInformation(
+ "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path",
+ manifest.ContentType,
+ manifest.Name);
+ continue;
+ }
+
+ logger.LogError(
+ "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'",
+ manifest.Name,
+ manifest.ContentType,
+ extractPath);
+ throw new InvalidDataException(
+ $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'.");
+ }
+
+ var instructions = BuildInstallationInstructions(manifest, filesWithHashes);
+
+ updatedManifests.Add(new ContentManifest
+ {
+ Id = manifest.Id,
+ Name = manifest.Name,
+ Version = manifest.Version,
+ ContentType = manifest.ContentType,
+ TargetGame = manifest.TargetGame,
+ Publisher = manifest.Publisher,
+ Metadata = manifest.Metadata,
+ Files = manifestFiles,
+ Dependencies = manifest.Dependencies,
+ InstallationInstructions = instructions,
+ });
+ }
+
+ ReconcileMissingMapPackDependencies(updatedManifests);
+
+ return updatedManifests;
+ }
+
+ private async Task> ScanExtractedFilesAsync(
+ string extractPath,
+ CancellationToken cancellationToken)
+ {
var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories);
logger.LogInformation("Processing {Count} files", allFiles.Length);
- List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsGameData)> filesWithHashes = [];
+ var filesWithHashes = new List(allFiles.Length);
foreach (var filePath in allFiles)
{
@@ -532,11 +627,9 @@ private async Task> UpdateManifestsWithExtractedFiles(
var relativePath = Path.GetRelativePath(extractPath, filePath);
var fileInfo = new FileInfo(filePath);
- // Determine if this file is inside the Maps directory
var isMap = relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + "/", StringComparison.OrdinalIgnoreCase);
- // Determine if this file is inside the GeneralsOnlineGameData directory
var isGameData = relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + "/", StringComparison.OrdinalIgnoreCase);
@@ -547,151 +640,137 @@ private async Task> UpdateManifestsWithExtractedFiles(
hash = Convert.ToHexString(hashBytes).ToLowerInvariant();
}
- filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isGameData));
+ filesWithHashes.Add(new ExtractedFileInfo(relativePath, fileInfo, hash, isMap, isGameData));
logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isGameData: {IsGameData})", relativePath, fileInfo.Length, hash[..8], isMap, isGameData);
}
- List updatedManifests = [];
+ return filesWithHashes;
+ }
- foreach (var manifest in manifests)
- {
- List manifestFiles = [];
- var isMapPackManifest = manifest.ContentType == ContentType.MapPack;
- var isPatchManifest = manifest.ContentType == ContentType.Patch;
+ private List BuildManifestFilesForManifest(
+ ContentManifest manifest,
+ List filesWithHashes)
+ {
+ var manifestFiles = new List();
- if (isMapPackManifest)
+ if (manifest.ContentType == ContentType.MapPack)
+ {
+ foreach (var file in filesWithHashes)
{
- // MapPack manifest: only include map files with UserMapsDirectory install target
- foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes)
+ if (file.IsMap)
{
- if (!isMap)
- {
- continue;
- }
-
- manifestFiles.Add(CreateMapManifestFile(relativePath, fileInfo, hash));
+ manifestFiles.Add(CreateMapManifestFile(file.RelativePath, file.FileInfo, file.Hash));
}
-
- logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count);
}
- else if (isPatchManifest)
+
+ logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count);
+ }
+ else if (manifest.ContentType == ContentType.Patch)
+ {
+ foreach (var file in filesWithHashes)
{
- // Data patch manifest: only include GeneralsOnlineGameData files with UserDataDirectory install target
- foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes)
+ if (file.IsGameData)
{
- if (!isGameData)
- {
- continue;
- }
-
- manifestFiles.Add(CreateGameDataManifestFile(relativePath, fileInfo, hash));
+ manifestFiles.Add(CreateGameDataManifestFile(file.RelativePath, file.FileInfo, file.Hash));
}
-
- logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count);
}
- else
- {
- // Game client manifest: include executables and shared files (skipping maps and game data files)
- // 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 && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable));
-
- var targetExecutable = hasEacLauncher
- ? GameClientConstants.GeneralsOnlineEacLauncherExecutable
- : GameClientConstants.GeneralsOnline60HzExecutable;
-
- foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes)
- {
- var isExecutable = false;
-
- // Skip map files and game data files in GameClient manifests
- if (isMap || isGameData)
- {
- continue;
- }
-
- if (IsArchiveRootFile(relativePath, targetExecutable))
- {
- isExecutable = true;
- }
- manifestFiles.Add(new ManifestFile
- {
- RelativePath = relativePath,
- Size = fileInfo.Length,
- Hash = hash,
- SourceType = ContentSourceType.ContentAddressable,
- SourcePath = fileInfo.FullName,
- InstallTarget = ContentInstallTarget.Workspace,
- IsExecutable = isExecutable,
- });
- }
+ logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count);
+ }
+ else
+ {
+ var hasEacLauncher = filesWithHashes.Any(file =>
+ !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable));
- logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count);
- }
+ var targetExecutable = hasEacLauncher
+ ? GameClientConstants.GeneralsOnlineEacLauncherExecutable
+ : GameClientConstants.GeneralsOnline60HzExecutable;
- if (manifestFiles.Count == 0)
+ foreach (var file in filesWithHashes)
{
- if (isMapPackManifest || isPatchManifest)
+ if (file.IsMap || file.IsGameData)
{
- logger.LogInformation(
- "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path",
- manifest.ContentType,
- manifest.Name);
continue;
}
- if (manifest.ContentType == ContentType.GameClient)
- {
- logger.LogError(
- "GameClient manifest '{Name}' has zero files in extract path '{ExtractPath}'",
- manifest.Name,
- extractPath);
- throw new InvalidDataException(
- $"GameClient manifest '{manifest.Name}' has no files in extract path '{extractPath}'.");
- }
+ var isExecutable = IsArchiveRootFile(file.RelativePath, targetExecutable);
- logger.LogError(
- "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'",
- manifest.Name,
- manifest.ContentType,
- extractPath);
- throw new InvalidDataException(
- $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'.");
+ manifestFiles.Add(new ManifestFile
+ {
+ RelativePath = file.RelativePath,
+ Size = file.FileInfo.Length,
+ Hash = file.Hash,
+ SourceType = ContentSourceType.ContentAddressable,
+ SourcePath = file.FileInfo.FullName,
+ InstallTarget = ContentInstallTarget.Workspace,
+ IsExecutable = isExecutable,
+ });
}
- updatedManifests.Add(new ContentManifest
+ logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count);
+ }
+
+ return manifestFiles;
+ }
+
+ private InstallationInstructions BuildInstallationInstructions(
+ ContentManifest manifest,
+ List filesWithHashes)
+ {
+ var hasEacSetup = filesWithHashes.Any(file =>
+ !file.IsMap && !file.IsGameData &&
+ IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable));
+
+ var inheritedPostSteps = (manifest.InstallationInstructions?.PostInstallSteps ?? [])
+ .Where(s => s != null && (hasEacSetup || !string.Equals(
+ s.TargetRelativePath,
+ GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ StringComparison.OrdinalIgnoreCase)));
+
+ var instructions = new InstallationInstructions
+ {
+ WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy,
+ DownloadHash = manifest.InstallationInstructions?.DownloadHash,
+ PostInstallSteps = [.. inheritedPostSteps],
+ };
+
+ if (manifest.ContentType == ContentType.GameClient &&
+ hasEacSetup &&
+ instructions.PostInstallSteps.All(s => s == null || (!string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase) && !string.Equals(s.StepKey, GeneralsOnlineConstants.EacStepKey, StringComparison.OrdinalIgnoreCase))))
+ {
+ instructions.PostInstallSteps.Add(new InstallationStep
{
- Id = manifest.Id,
- Name = manifest.Name,
- Version = manifest.Version,
- ContentType = manifest.ContentType,
- TargetGame = manifest.TargetGame,
- Publisher = manifest.Publisher,
- Metadata = manifest.Metadata,
- Files = manifestFiles,
- Dependencies = manifest.Dependencies,
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable,
+ Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId],
+ RequiresElevation = true,
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
});
}
- // If MapPack was not created from archive, remove MapPack dependency so dependency resolution does not fail
- var hasMapPack = updatedManifests.Any(m => m.ContentType == ContentType.MapPack);
- if (!hasMapPack)
+ return instructions;
+ }
+
+ private void ReconcileMissingMapPackDependencies(List manifests)
+ {
+ var hasMapPack = manifests.Any(m => m.ContentType == ContentType.MapPack);
+ if (hasMapPack)
{
- foreach (var m in updatedManifests)
+ return;
+ }
+
+ foreach (var m in manifests)
+ {
+ if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack))
{
- if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack))
- {
- logger.LogWarning(
- "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive",
- m.Name);
- m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList();
- }
+ logger.LogWarning(
+ "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive",
+ m.Name);
+ m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList();
}
}
-
- return updatedManifests;
}
}
diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs
index 3c842779c..6a800a122 100644
--- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs
@@ -1,3 +1,9 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Interfaces.Manifest;
@@ -10,11 +16,6 @@
using GenHub.Core.Models.Results.Content;
using GenHub.Features.Content.Services.ContentProviders;
using Microsoft.Extensions.Logging;
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
namespace GenHub.Features.Content.Services.GeneralsOnline;
@@ -28,10 +29,12 @@ public class GeneralsOnlineProvider(
IEnumerable resolvers,
IEnumerable deliverers,
IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService,
IContentManifestPool manifestPool,
ILogger logger)
- : BaseContentProvider(contentValidator, logger)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
+ private readonly ConcurrentDictionary> _preExistingManifestIdsByManifest = new(StringComparer.OrdinalIgnoreCase);
private ProviderDefinition? _cachedProviderDefinition;
///
@@ -201,6 +204,12 @@ protected override async Task> PrepareContentIn
IProgress? progress,
CancellationToken cancellationToken)
{
+ if (!OperatingSystem.IsWindows())
+ {
+ return OperationResult.CreateFailure(
+ "GeneralsOnline is currently supported only on Windows. Easy Anti-Cheat was not designed for Wine/Proton environments.");
+ }
+
Logger.LogInformation("Preparing Generals Online content: {Version}", manifest.Version);
try
@@ -212,6 +221,21 @@ protected override async Task> PrepareContentIn
$"Cannot deliver content for manifest {manifest.Id}");
}
+ var existingPool = await manifestPool.GetAllManifestsAsync(cancellationToken);
+ if (!existingPool.Success || existingPool.Data == null)
+ {
+ return OperationResult.CreateFailure(
+ $"Failed to query existing manifests before delivery: {existingPool.FirstError}");
+ }
+
+ var preExisting = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var m in existingPool.Data)
+ {
+ preExisting.Add(m.Id);
+ }
+
+ _preExistingManifestIdsByManifest[manifest.Id] = preExisting;
+
var deliveryResult = await Deliverer.DeliverContentAsync(
manifest,
workingDirectory,
@@ -241,4 +265,63 @@ protected override async Task> PrepareContentIn
$"Content preparation failed: {ex.Message}");
}
}
+
+ ///
+ protected override async Task RollbackPreparedContentAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ Logger.LogWarning("Rolling back Generals Online manifest registration for version {Version}", preparedManifest.Version);
+
+ try
+ {
+ if (!_preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds) || preExistingIds == null)
+ {
+ Logger.LogWarning(
+ "No pre-delivery manifest snapshot found for {ManifestId}; skipping rollback manifest unregistration to avoid removing existing content",
+ originalManifest.Id);
+ return;
+ }
+
+ var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken);
+ if (allManifestsResult.Success && allManifestsResult.Data != null)
+ {
+ var matchingManifests = allManifestsResult.Data
+ .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) &&
+ !preExistingIds.Contains(m.Id))
+ .ToList();
+
+ foreach (var manifest in matchingManifests)
+ {
+ var removeResult = await manifestPool.RemoveManifestAsync(manifest.Id, cancellationToken: cancellationToken);
+ if (!removeResult.Success)
+ {
+ Logger.LogWarning("Failed to remove manifest {ManifestId} during rollback: {Error}", manifest.Id, removeResult.FirstError);
+ }
+ else
+ {
+ Logger.LogInformation("Unregistered manifest {ManifestId} during rollback", manifest.Id);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error occurred during Generals Online manifest registration rollback");
+ }
+ }
+
+ ///
+ protected override Task OnContentPreparationCompletedAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out _);
+ return Task.CompletedTask;
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
index 4f9358e22..366cb6e41 100644
--- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs
@@ -24,8 +24,9 @@ public class GitHubContentProvider(
IEnumerable resolvers,
IEnumerable deliverers,
ILogger logger,
- IContentValidator contentValidator)
- : BaseContentProvider(contentValidator, logger)
+ IContentValidator contentValidator,
+ IInstallationInstructionsService installationInstructionsService)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
///
public override string SourceName => "GitHub";
diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs
new file mode 100644
index 000000000..bae5c14e2
--- /dev/null
+++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs
@@ -0,0 +1,657 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Helpers;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Notifications;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using Microsoft.Extensions.Logging;
+
+namespace GenHub.Features.Content.Services;
+
+///
+/// Service for validating and executing manifest-declared installation steps.
+/// Enforces trust boundaries, path containment, and hash verification before execution.
+///
+/// The file hash provider for integrity verification.
+/// The notification service for user awareness.
+/// The user settings service for tracking executed installation steps across updates.
+/// Optional installation step preconditions for environment detection.
+/// The logger instance.
+public class InstallationInstructionsService(
+ IFileHashProvider hashProvider,
+ INotificationService notificationService,
+ IUserSettingsService? userSettingsService,
+ IEnumerable? preconditions,
+ ILogger logger) : IInstallationInstructionsService
+{
+ private static readonly TimeSpan InstallerStepTimeout = TimeSpan.FromMinutes(10);
+ private readonly SemaphoreSlim _executionGate = new(1, 1);
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The file hash provider for integrity verification.
+ /// The notification service for user awareness.
+ /// The user settings service for tracking executed installation steps across updates.
+ /// The logger instance.
+ public InstallationInstructionsService(
+ IFileHashProvider hashProvider,
+ INotificationService notificationService,
+ IUserSettingsService? userSettingsService,
+ ILogger logger)
+ : this(hashProvider, notificationService, userSettingsService, null, logger)
+ {
+ }
+
+ ///
+ public async Task ExecutePostInstallStepsAsync(
+ ContentManifest manifest,
+ string workingDirectory,
+ string? providerSource = null,
+ bool force = false,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(manifest);
+
+ if (manifest.InstallationInstructions?.PostInstallSteps == null ||
+ manifest.InstallationInstructions.PostInstallSteps.Count == 0)
+ {
+ return OperationResult.CreateSuccess();
+ }
+
+ logger.LogInformation(
+ "Executing {Count} post-install step(s) for manifest {ManifestId} from provider {Provider} (force: {Force})",
+ manifest.InstallationInstructions.PostInstallSteps.Count,
+ manifest.Id,
+ providerSource ?? "unspecified",
+ force);
+
+ return await ExecuteStepsAsync(
+ manifest.InstallationInstructions.PostInstallSteps,
+ manifest,
+ workingDirectory,
+ providerSource,
+ force,
+ progress,
+ cancellationToken);
+ }
+
+ private async Task ExecuteStepsAsync(
+ IReadOnlyList steps,
+ ContentManifest manifest,
+ string workingDirectory,
+ string? providerSource,
+ bool force,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory))
+ {
+ return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'");
+ }
+
+ await _executionGate.WaitAsync(cancellationToken);
+ try
+ {
+ for (var i = 0; i < steps.Count; i++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var step = steps[i];
+
+ if (step == null)
+ {
+ continue;
+ }
+
+ var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken);
+ if (!stepResult.Success)
+ {
+ return stepResult;
+ }
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+ finally
+ {
+ _executionGate.Release();
+ }
+ }
+
+ private async Task ExecuteSingleStepAsync(
+ InstallationStep step,
+ ContentManifest manifest,
+ string workingDirectory,
+ string? providerSource,
+ bool force,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var stepKey = GetStepKey(step, manifest);
+
+ if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken))
+ {
+ logger.LogInformation(
+ "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})",
+ step.Name,
+ manifest.Id,
+ stepKey);
+
+ progress?.Report(new ContentAcquisitionProgress
+ {
+ Phase = ContentAcquisitionPhase.Delivering,
+ CurrentOperation = $"Skipping {step.Name} (already installed)",
+ CurrentFile = step.TargetRelativePath ?? string.Empty,
+ });
+
+ return OperationResult.CreateSuccess();
+ }
+
+ var authResult = ValidateProviderAuthorization(providerSource, manifest, step);
+ if (!authResult.Success)
+ {
+ return authResult;
+ }
+
+ var result = OperationResult.CreateFailure("Uninitialized step result");
+ switch (step.Kind)
+ {
+ case InstallationStepKind.RunVerifiedInstaller:
+ result = await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken);
+ break;
+
+ case InstallationStepKind.RemoveFile:
+ result = ExecuteRemoveFile(step, workingDirectory);
+ break;
+
+ case InstallationStepKind.RenameFile:
+ result = ExecuteRenameFile(step, workingDirectory);
+ break;
+
+ default:
+ logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name);
+ return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'.");
+ }
+
+ if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey))
+ {
+ await RecordStepExecutedAsync(stepKey, cancellationToken);
+ }
+
+ return result;
+ }
+
+ private async Task ShouldSkipStepAsync(
+ InstallationStep step,
+ string stepKey,
+ ContentManifest manifest,
+ CancellationToken cancellationToken)
+ {
+ if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true)
+ {
+ return true;
+ }
+
+ if (preconditions != null)
+ {
+ foreach (var precondition in preconditions)
+ {
+ if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest))
+ {
+ if (!string.IsNullOrWhiteSpace(stepKey))
+ {
+ await RecordStepExecutedAsync(stepKey, cancellationToken);
+ }
+
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private async Task RecordStepExecutedAsync(string stepKey, CancellationToken cancellationToken)
+ {
+ if (userSettingsService == null || string.IsNullOrWhiteSpace(stepKey))
+ {
+ return;
+ }
+
+ userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey));
+
+ try
+ {
+ await userSettingsService.SaveAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey);
+ }
+ }
+
+ private string GetStepKey(InstallationStep step, ContentManifest manifest)
+ {
+ if (!string.IsNullOrWhiteSpace(step.StepKey))
+ {
+ return step.StepKey;
+ }
+
+ var publisher = manifest.Publisher?.PublisherType ?? "generic";
+ var manifestId = manifest.Id.Value ?? string.Empty;
+ var name = step.Name;
+ var target = step.TargetRelativePath ?? string.Empty;
+ var args = step.Arguments is { Count: > 0 } ? string.Join(" ", step.Arguments) : string.Empty;
+
+ return $"{publisher}:{manifestId}:{name}:{target}:{args}".TrimEnd(':');
+ }
+
+ private async Task ExecuteRunVerifiedInstallerAsync(
+ InstallationStep step,
+ ContentManifest manifest,
+ string workingDirectory,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var pathResult = ValidateInstallerTargetPath(step, workingDirectory, out var targetFullPath);
+ if (!pathResult.Success)
+ {
+ return pathResult;
+ }
+
+ var integrityResult = await VerifyInstallerIntegrityAsync(step, manifest, targetFullPath, cancellationToken);
+ if (!integrityResult.Success)
+ {
+ return integrityResult;
+ }
+
+ NotifyStepStarting(step, progress);
+
+ logger.LogInformation(
+ "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}",
+ step.TargetRelativePath,
+ step.RequiresElevation,
+ manifest.Id);
+
+ return await RunInstallerProcessAsync(step, targetFullPath, workingDirectory, cancellationToken);
+ }
+
+ private OperationResult ValidateProviderAuthorization(string? providerSource, ContentManifest manifest, InstallationStep step)
+ {
+ var effectiveSource = !string.IsNullOrWhiteSpace(providerSource)
+ ? providerSource
+ : string.Empty;
+
+ var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(effectiveSource);
+
+ if (!isTrusted)
+ {
+ logger.LogError(
+ "Untrusted provider '{ProviderSource}' attempted to execute step '{StepName}' (Kind: {Kind}) for manifest {ManifestId}",
+ effectiveSource,
+ step.Name,
+ step.Kind,
+ manifest.Id);
+
+ return OperationResult.CreateFailure(
+ $"Provider '{(!string.IsNullOrEmpty(effectiveSource) ? effectiveSource : "unknown")}' is not authorized to execute installation steps.");
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+
+ private OperationResult ValidateInstallerTargetPath(InstallationStep step, string workingDirectory, out string targetFullPath)
+ {
+ targetFullPath = string.Empty;
+
+ if (string.IsNullOrWhiteSpace(step.TargetRelativePath))
+ {
+ return OperationResult.CreateFailure($"Target relative path is required for executable step '{step.Name}'.");
+ }
+
+ var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath);
+ targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath);
+
+ if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath))
+ {
+ logger.LogError("Target installer path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory);
+ return OperationResult.CreateFailure($"Installer path '{step.TargetRelativePath}' escapes the working directory.");
+ }
+
+ if (!File.Exists(targetFullPath))
+ {
+ logger.LogError("Installer executable not found at '{Path}'", targetFullPath);
+ return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' was not found in delivered content.");
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+
+ private async Task VerifyInstallerIntegrityAsync(
+ InstallationStep step,
+ ContentManifest manifest,
+ string targetFullPath,
+ CancellationToken cancellationToken)
+ {
+ var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath ?? string.Empty);
+ var manifestFile = manifest.Files?.FirstOrDefault(f =>
+ string.Equals(
+ PathHelper.NormalizeRelativePath(f.RelativePath),
+ normalizedRelativePath,
+ PathHelper.PathComparison));
+
+ if (manifestFile == null)
+ {
+ logger.LogError("Executable '{Target}' is not declared in manifest files for {ManifestId}", step.TargetRelativePath, manifest.Id);
+ return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' is not declared in manifest files.");
+ }
+
+ if (string.IsNullOrWhiteSpace(manifestFile.Hash))
+ {
+ logger.LogError("Installer '{Target}' has no declared hash in manifest {ManifestId}", step.TargetRelativePath, manifest.Id);
+ return OperationResult.CreateFailure(
+ $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified.");
+ }
+
+ var computedHash = string.Empty;
+ try
+ {
+ computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to compute hash for installer '{Target}' in manifest {ManifestId}", step.TargetRelativePath, manifest.Id);
+ return OperationResult.CreateFailure($"Failed to compute hash for installer '{step.TargetRelativePath}': {ex.Message}");
+ }
+
+ if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase))
+ {
+ logger.LogError(
+ "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}",
+ step.TargetRelativePath,
+ manifestFile.Hash,
+ computedHash);
+
+ return OperationResult.CreateFailure(
+ $"Integrity verification failed for installer '{step.TargetRelativePath}'.");
+ }
+
+ logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath);
+ return OperationResult.CreateSuccess();
+ }
+
+ private void NotifyStepStarting(InstallationStep step, IProgress? progress)
+ {
+ var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step";
+ var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage)
+ ? step.StatusMessage
+ : $"Executing verified installer '{step.TargetRelativePath}'";
+
+ notificationService.ShowInfo(
+ displayTitle,
+ displayMessage,
+ NotificationConstants.DefaultAutoDismissMs);
+
+ progress?.Report(new ContentAcquisitionProgress
+ {
+ Phase = ContentAcquisitionPhase.Delivering,
+ CurrentOperation = displayMessage,
+ CurrentFile = step.TargetRelativePath ?? string.Empty,
+ });
+ }
+
+ private async Task RunInstallerProcessAsync(
+ InstallationStep step,
+ string targetFullPath,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = targetFullPath,
+ WorkingDirectory = workingDirectory,
+ };
+
+ if (step.Arguments is { Count: > 0 })
+ {
+ foreach (var arg in step.Arguments)
+ {
+ startInfo.ArgumentList.Add(arg);
+ }
+ }
+
+ if (step.RequiresElevation)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ logger.LogError("Installation step '{StepName}' requires administrator elevation, which is only supported on Windows", step.Name);
+ return OperationResult.CreateFailure(
+ $"Installation step '{step.Name}' requires administrator elevation, which is only supported on Windows.");
+ }
+
+ startInfo.UseShellExecute = true;
+ startInfo.Verb = "runas";
+ }
+ else
+ {
+ startInfo.UseShellExecute = false;
+ startInfo.CreateNoWindow = true;
+ }
+
+ try
+ {
+ using var process = Process.Start(startInfo);
+ if (process == null)
+ {
+ logger.LogError("Failed to start process for installer '{Target}'", step.TargetRelativePath);
+ notificationService.ShowError("Installation Step Failed", $"Failed to start installer '{step.Name}'.");
+ return OperationResult.CreateFailure($"Failed to start installer '{step.TargetRelativePath}'.");
+ }
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(InstallerStepTimeout);
+
+ try
+ {
+ await process.WaitForExitAsync(timeoutCts.Token);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ logger.LogError("Installer step '{StepName}' timed out", step.Name);
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ }
+ catch (Exception killEx)
+ {
+ logger.LogWarning(killEx, "Failed to terminate timed-out installer step '{StepName}'", step.Name);
+ }
+
+ notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out.");
+ return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ logger.LogInformation("Installation step '{StepName}' was canceled by caller, killing process tree", step.Name);
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill(entireProcessTree: true);
+ await process.WaitForExitAsync(CancellationToken.None);
+ }
+ }
+ catch (Exception killEx)
+ {
+ logger.LogWarning(killEx, "Failed to terminate canceled installer step '{StepName}'", step.Name);
+ }
+
+ throw;
+ }
+
+ if (process.ExitCode != 0)
+ {
+ logger.LogError(
+ "Installer step '{StepName}' exited with error code {ExitCode}",
+ step.Name,
+ process.ExitCode);
+
+ notificationService.ShowError(
+ "Installation Step Failed",
+ $"Step '{step.Name}' failed with exit code {process.ExitCode}.");
+
+ return OperationResult.CreateFailure(
+ $"Installation step '{step.Name}' failed with exit code {process.ExitCode}.");
+ }
+
+ logger.LogInformation("Successfully completed installer step '{StepName}'", step.Name);
+ notificationService.ShowSuccess(
+ "Installation Step Completed",
+ $"Successfully completed '{step.Name}'.");
+
+ return OperationResult.CreateSuccess();
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogInformation("Installation step '{StepName}' was canceled", step.Name);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to execute installer step '{StepName}'", step.Name);
+ notificationService.ShowError(
+ "Installation Step Error",
+ $"Error executing '{step.Name}': {ex.Message}");
+
+ return OperationResult.CreateFailure($"Execution of step '{step.Name}' failed: {ex.Message}");
+ }
+ }
+
+ private OperationResult ExecuteRemoveFile(InstallationStep step, string workingDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(step.TargetRelativePath))
+ {
+ return OperationResult.CreateFailure($"Target relative path is required for remove file step '{step.Name}'.");
+ }
+
+ var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath);
+ var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath);
+
+ if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath))
+ {
+ logger.LogError("Target remove path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory);
+ return OperationResult.CreateFailure($"Target file '{step.TargetRelativePath}' escapes the working directory.");
+ }
+
+ try
+ {
+ if (File.Exists(targetFullPath))
+ {
+ File.Delete(targetFullPath);
+ logger.LogInformation("Deleted file '{Target}' as part of step '{StepName}'", step.TargetRelativePath, step.Name);
+ }
+ else
+ {
+ logger.LogDebug("File '{Target}' already absent during remove step '{StepName}'", step.TargetRelativePath, step.Name);
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete file '{Target}' in step '{StepName}'", step.TargetRelativePath, step.Name);
+ return OperationResult.CreateFailure($"Failed to delete file '{step.TargetRelativePath}': {ex.Message}");
+ }
+ }
+
+ private OperationResult ExecuteRenameFile(InstallationStep step, string workingDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(step.TargetRelativePath))
+ {
+ return OperationResult.CreateFailure($"Target relative path is required for rename step '{step.Name}'.");
+ }
+
+ if (string.IsNullOrWhiteSpace(step.DestinationRelativePath))
+ {
+ return OperationResult.CreateFailure($"Destination relative path is required for rename step '{step.Name}'.");
+ }
+
+ var normalizedSourcePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath);
+ var normalizedDestPath = PathHelper.NormalizeRelativePath(step.DestinationRelativePath);
+
+ var sourceFullPath = Path.Combine(workingDirectory, normalizedSourcePath);
+ var destFullPath = Path.Combine(workingDirectory, normalizedDestPath);
+
+ if (!PathHelper.IsPathWithinDirectory(workingDirectory, sourceFullPath))
+ {
+ logger.LogError("Source path '{Source}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory);
+ return OperationResult.CreateFailure($"Source path '{step.TargetRelativePath}' escapes the working directory.");
+ }
+
+ if (!PathHelper.IsPathWithinDirectory(workingDirectory, destFullPath))
+ {
+ logger.LogError("Destination path '{Dest}' escapes working directory '{Dir}'", step.DestinationRelativePath, workingDirectory);
+ return OperationResult.CreateFailure($"Destination path '{step.DestinationRelativePath}' escapes the working directory.");
+ }
+
+ try
+ {
+ if (File.Exists(sourceFullPath))
+ {
+ var destDir = Path.GetDirectoryName(destFullPath);
+ if (!string.IsNullOrEmpty(destDir))
+ {
+ Directory.CreateDirectory(destDir);
+ }
+
+ File.Move(sourceFullPath, destFullPath, overwrite: true);
+ logger.LogInformation(
+ "Renamed '{Source}' to '{Dest}' in step '{StepName}'",
+ step.TargetRelativePath,
+ step.DestinationRelativePath,
+ step.Name);
+ }
+ else
+ {
+ logger.LogWarning("Source file '{Source}' does not exist for rename step '{StepName}'", step.TargetRelativePath, step.Name);
+ }
+
+ return OperationResult.CreateSuccess();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Failed to rename '{Source}' to '{Dest}' in step '{StepName}'",
+ step.TargetRelativePath,
+ step.DestinationRelativePath,
+ step.Name);
+
+ return OperationResult.CreateFailure(
+ $"Failed to rename '{step.TargetRelativePath}' to '{step.DestinationRelativePath}': {ex.Message}");
+ }
+ }
+}
diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
index 3f1b78008..d653e90f4 100644
--- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
@@ -28,8 +28,9 @@ public class SuperHackersProvider(
IEnumerable resolvers,
IEnumerable deliverers,
IContentValidator contentValidator,
- ILogger logger)
- : BaseContentProvider(contentValidator, logger)
+ ILogger logger,
+ IInstallationInstructionsService installationInstructionsService)
+ : BaseContentProvider(contentValidator, installationInstructionsService, logger)
{
private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r =>
r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true)
diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
index 933b635d9..766532f76 100644
--- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
@@ -956,7 +956,7 @@ private async Task> AdoptExpectedChildProcessAs
// Terminated first, so the launcher has exited and its stderr drains in full.
return OperationResult.CreateFailure(
AppendLauncherErrors(
- $"Cannot adopt {expectedName}: the launcher's start time could not be read.",
+ $"Launcher exited without starting {expectedName}: the launcher's start time could not be read.",
launcher,
capturedErrors));
}
diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs
index fcecce36f..828107c9c 100644
--- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs
+++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs
@@ -255,6 +255,28 @@ public IContentManifestBuilder WithPublisher(
return this;
}
+ ///
+ public IContentManifestBuilder WithPublisher(PublisherInfo publisher)
+ {
+ ArgumentNullException.ThrowIfNull(publisher);
+
+ _manifest.Publisher = new PublisherInfo
+ {
+ Name = publisher.Name,
+ PublisherType = publisher.PublisherType,
+ Website = publisher.Website,
+ SupportUrl = publisher.SupportUrl,
+ ContactEmail = publisher.ContactEmail,
+ UpdateApiEndpoint = publisher.UpdateApiEndpoint,
+ ContentIndexUrl = publisher.ContentIndexUrl,
+ UpdateCheckIntervalHours = publisher.UpdateCheckIntervalHours,
+ SupportsIncrementalUpdates = publisher.SupportsIncrementalUpdates,
+ AuthenticationMethod = publisher.AuthenticationMethod,
+ };
+ logger.LogDebug("Set publisher: {PublisherName} (Type: {PublisherType})", publisher.Name, publisher.PublisherType);
+ return this;
+ }
+
///
/// Sets the metadata for the manifest.
///
@@ -358,6 +380,16 @@ public IContentManifestBuilder AddContentReference(
return this;
}
+ ///
+ public IContentManifestBuilder WithContentReferences(IEnumerable contentReferences)
+ {
+ ArgumentNullException.ThrowIfNull(contentReferences);
+
+ _manifest.ContentReferences = [.. contentReferences];
+ logger.LogDebug("Set {Count} content references", _manifest.ContentReferences.Count);
+ return this;
+ }
+
///
/// Adds files from a directory to the manifest.
///
@@ -512,6 +544,7 @@ public Task AddContentAddressableFileAsync(
{
RelativePath = relativePath,
SourceType = ContentSourceType.ContentAddressable,
+ InstallTarget = DetermineInstallTarget(relativePath),
IsExecutable = isExecutable,
Hash = hash,
Size = size,
@@ -614,69 +647,86 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie
public IContentManifestBuilder WithInstallationInstructions(
WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy)
{
- _manifest.InstallationInstructions = new InstallationInstructions
- {
- WorkspaceStrategy = workspaceStrategy,
- };
+ _manifest.InstallationInstructions = _manifest.InstallationInstructions == null
+ ? new InstallationInstructions { WorkspaceStrategy = workspaceStrategy }
+ : new InstallationInstructions
+ {
+ WorkspaceStrategy = workspaceStrategy,
+ DownloadHash = _manifest.InstallationInstructions.DownloadHash,
+ PostInstallSteps = _manifest.InstallationInstructions.PostInstallSteps == null
+ ? []
+ : [.. _manifest.InstallationInstructions.PostInstallSteps],
+ };
+
logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy);
return this;
}
- ///
- /// Adds a pre-installation step to the manifest.
- ///
- /// Step name.
- /// Command.
- /// Arguments.
- /// Working directory.
- /// Requires elevation.
- /// The builder instance.
- public IContentManifestBuilder AddPreInstallStep(
- string name,
- string command,
- List? arguments = null,
- string workingDirectory = "",
- bool requiresElevation = false)
+ ///
+ public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions)
{
- var step = new InstallationStep
+ ArgumentNullException.ThrowIfNull(installationInstructions);
+
+ _manifest.InstallationInstructions = new InstallationInstructions
{
- Name = name,
- Command = command,
- Arguments = arguments ?? [],
- WorkingDirectory = workingDirectory,
- RequiresElevation = requiresElevation,
+ WorkspaceStrategy = installationInstructions.WorkspaceStrategy,
+ DownloadHash = installationInstructions.DownloadHash,
+ PostInstallSteps = installationInstructions.PostInstallSteps == null
+ ? []
+ : [.. installationInstructions.PostInstallSteps],
};
- _manifest.InstallationInstructions.PreInstallSteps.Add(step);
- logger.LogDebug("Added pre-install step: {StepName}", name);
+
+ logger.LogDebug(
+ "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps",
+ _manifest.InstallationInstructions.WorkspaceStrategy,
+ _manifest.InstallationInstructions.PostInstallSteps.Count);
return this;
}
- ///
- /// Adds a post-installation step to the manifest.
- ///
- /// Step name.
- /// Command.
- /// Arguments.
- /// Working directory.
- /// Requires elevation.
- /// The builder instance.
+ ///
public IContentManifestBuilder AddPostInstallStep(
string name,
- string command,
+ InstallationStepKind kind,
+ string? targetRelativePath = null,
List? arguments = null,
- string workingDirectory = "",
- bool requiresElevation = false)
+ string? destinationRelativePath = null,
+ bool requiresElevation = false,
+ string? statusMessage = null,
+ bool runOnce = false,
+ string? stepKey = null)
{
var step = new InstallationStep
{
Name = name,
- Command = command,
- Arguments = arguments ?? [],
- WorkingDirectory = workingDirectory,
+ Kind = kind,
+ TargetRelativePath = targetRelativePath,
+ Arguments = arguments,
+ DestinationRelativePath = destinationRelativePath,
RequiresElevation = requiresElevation,
+ StatusMessage = statusMessage,
+ RunOnce = runOnce,
+ StepKey = stepKey,
};
+ return AddPostInstallStep(step);
+ }
+
+ ///
+ public IContentManifestBuilder AddPostInstallStep(InstallationStep step)
+ {
+ ArgumentNullException.ThrowIfNull(step);
+ if (step.Kind == InstallationStepKind.Unknown)
+ {
+ throw new ArgumentException("Installation step kind cannot be Unknown.", nameof(step));
+ }
+
+ if (string.IsNullOrWhiteSpace(step.Name))
+ {
+ throw new ArgumentException("Installation step name cannot be empty or whitespace.", nameof(step));
+ }
+
+ _manifest.InstallationInstructions ??= new InstallationInstructions();
_manifest.InstallationInstructions.PostInstallSteps.Add(step);
- logger.LogDebug("Added post-install step: {StepName}", name);
+ logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce);
return this;
}
diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
index ea7246dec..322c7bbe2 100644
--- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
+++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs
@@ -361,5 +361,11 @@ private static void AddSharedComponents(IServiceCollection services)
// Register content orchestrator and validator
services.AddSingleton();
+
+ // Register installation step preconditions
+ services.AddSingleton();
+
+ // Register installation instructions execution service
+ services.AddSingleton();
}
}