Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5fd5ecd
feat(content): implement manifest installation instructions service a…
undead2146 Aug 19, 2026
79b398a
feat(content): add RunOnce step skipping and user settings persistenc…
undead2146 Aug 19, 2026
403a025
fix(content): resolve GetValidatedContentAsync interface implementati…
undead2146 Aug 19, 2026
dffa05f
fix(content): align BaseContentProvider pipeline and fix StyleCop war…
undead2146 Aug 19, 2026
b637a55
fix(content): resolve review feedback, add precondition abstraction, …
undead2146 Aug 19, 2026
999fd69
fix(content): restore TestContentProvider Deliverer and reduce Update…
undead2146 Aug 19, 2026
ca03146
fix(tests): resolve type ambiguities, constructor compatibility, and …
undead2146 Aug 19, 2026
86f6c61
fix(core): resolve StyleCop warnings, doc comments, and ContentType d…
undead2146 Aug 19, 2026
88d657c
fix(quality): address DeepSource findings for constructor visibility …
undead2146 Aug 19, 2026
58c51df
fix(eac): check specific EAC EOS product ID registry key instead of g…
undead2146 Aug 19, 2026
0df2e88
fix(installer): kill process tree on timeout and drop inherited EAC s…
undead2146 Aug 19, 2026
bb3d9fe
refactor(installer): decompose ExecuteRunVerifiedInstallerAsync to re…
undead2146 Aug 19, 2026
3df4779
fix(installer): address review feedback for authorization, containmen…
Aug 19, 2026
c9f5426
fix(core): resolve DeepSource lambda expressions and constructor acce…
Aug 19, 2026
2c03c14
fix(content): harden installation instructions step lifecycle and del…
Aug 19, 2026
ad06b41
fix(security): resolve all intermediate path components in symlink co…
Aug 19, 2026
c0ed8d7
fix(core): recursively resolve symlink target paths in PathHelper.Fol…
Aug 19, 2026
196833b
fix(core): resolve DeepSource exception filters, cyclomatic complexit…
Aug 19, 2026
539edae
fix(content): add Windows platform guard to GeneralsOnlineProvider.Pr…
Aug 19, 2026
3c5846e
fix(review): address review feedback, containment tests, and DI regis…
Aug 20, 2026
9e6b5a3
fix(content): address review feedback across deliverers, providers, a…
Aug 20, 2026
9a57a33
fix(core): address DeepSource static analysis findings in preconditio…
Aug 20, 2026
f7d9e9f
fix(core): initialize computedHash local variable to satisfy static a…
Aug 20, 2026
09c3852
fix(content): persist RunOnce keys immediately, check skip before aut…
Aug 20, 2026
8b2f059
revert(content): remove out-of-scope HttpContentDeliverer modifications
Aug 20, 2026
c11d7d1
feat(generalsonline): propagate sha256 to manifest file and download …
Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,23 @@ public static class GeneralsOnlineConstants
/// <summary>Description for Generals Online deliverer.</summary>
public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage";

// ===== Easy Anti-Cheat Installation =====

/// <summary>Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online.</summary>
public const string EacProductId = "fc1cc0d936424212b645105f084d08b0";
Comment thread
undead2146 marked this conversation as resolved.

/// <summary>Setup command passed to EasyAntiCheat_EOS_Setup.exe.</summary>
public const string EacInstallCommand = "install";

/// <summary>Display name for the Easy Anti-Cheat installation step.</summary>
public const string EacStepName = "Install Easy Anti-Cheat";

/// <summary>Status message displayed to the user during Easy Anti-Cheat installation.</summary>
public const string EacStatusMessage = "Installing AntiCheat";

/// <summary>Unique step key identifying Easy Anti-Cheat installation for Generals Online.</summary>
public const string EacStepKey = PublisherType + ":eac:" + EacProductId;

// ===== Content Tags =====

/// <summary>Content tags for search and categorization.</summary>
Expand Down
15 changes: 15 additions & 0 deletions GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using GenHub.Core.Extensions.GameInstallations;
using GenHub.Core.Models.Enums;

Expand Down Expand Up @@ -59,6 +61,19 @@ public static class PublisherTypeConstants
/// <summary>Art of Defense Maps community site.</summary>
public const string AODMaps = "aodmaps";

/// <summary>GenHub internal system content publisher.</summary>
public const string GenHubInternal = "genhub";

/// <summary>
/// Set of publisher identifiers trusted to execute installation steps (e.g. installers).
/// </summary>
public static readonly IReadOnlySet<string> TrustedExecutablePublishers = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
GeneralsOnline,
CommunityOutpost,
TheSuperHackers,
};

/// <summary>
/// Maps GameInstallationType enum to publisher type string.
/// </summary>
Expand Down
113 changes: 92 additions & 21 deletions GenHub/GenHub.Core/Helpers/PathHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -76,11 +92,41 @@ public static string GetSafeParentDirectory(string path)
/// <returns><see langword="true"/> when the candidate resolves inside the base directory; otherwise, <see langword="false"/>.</returns>
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;
}
}

/// <summary>
/// Normalizes a relative path by standardizing directory separators and removing leading separators.
/// </summary>
/// <param name="relativePath">The relative path to normalize.</param>
/// <returns>The normalized relative path.</returns>
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)
Expand All @@ -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)
{
Expand All @@ -127,6 +194,10 @@ private static string FollowLinks(string fullPath)
{
return fullPath;
}
catch (SecurityException)
{
return fullPath;
}
catch (NotSupportedException)
{
return fullPath;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Service for validating and executing manifest-declared installation steps.
/// </summary>
public interface IInstallationInstructionsService
{
/// <summary>
/// Executes post-installation steps for the specified manifest, optionally forcing run-once steps.
/// </summary>
/// <param name="manifest">The content manifest declaring post-installation steps.</param>
/// <param name="workingDirectory">The working directory containing the content files.</param>
/// <param name="providerSource">The provider source name supplying the content, used for step authorization.</param>
/// <param name="force">Whether to force execution of steps marked as run-once even if already executed.</param>
/// <param name="progress">Optional progress reporter for acquisition status.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A result indicating whether all post-installation steps succeeded.</returns>
Task<OperationResult> ExecutePostInstallStepsAsync(
Comment thread
undead2146 marked this conversation as resolved.
ContentManifest manifest,
string workingDirectory,
string? providerSource = null,
bool force = false,
IProgress<ContentAcquisitionProgress>? progress = null,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using GenHub.Core.Models.Manifest;

namespace GenHub.Core.Interfaces.Content;

/// <summary>
/// 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.
/// </summary>
public interface IInstallationStepPrecondition
{
/// <summary>
/// Determines whether this precondition can handle the specified installation step.
/// </summary>
/// <param name="step">The installation step to inspect.</param>
/// <param name="manifest">The content manifest declaring the step.</param>
/// <returns><see langword="true"/> if this precondition applies to the step; otherwise, <see langword="false"/>.</returns>
bool CanHandle(InstallationStep step, ContentManifest manifest);

/// <summary>
/// Determines whether the step's goal is already fulfilled in the local environment.
/// </summary>
/// <param name="step">The installation step to evaluate.</param>
/// <param name="manifest">The content manifest declaring the step.</param>
/// <returns><see langword="true"/> if the step is already fulfilled; otherwise, <see langword="false"/>.</returns>
bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest);
}
52 changes: 41 additions & 11 deletions GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ public interface IContentManifestBuilder
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder WithPublisher(string name, string website = "", string supportUrl = "", string contactEmail = "", string publisherType = "");

/// <summary>
/// Sets publisher information from an existing <see cref="PublisherInfo"/> instance.
/// </summary>
/// <param name="publisher">The publisher information.</param>
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder WithPublisher(PublisherInfo publisher);

/// <summary>
/// Sets content metadata.
/// </summary>
Expand Down Expand Up @@ -207,26 +214,42 @@ IContentManifestBuilder AddDependency(
IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy);

/// <summary>
/// Adds a pre-installation step.
/// Sets the complete installation instructions object for the manifest.
/// </summary>
/// <param name="name">Step name.</param>
/// <param name="command">Command to execute.</param>
/// <param name="arguments">Command arguments.</param>
/// <param name="workingDirectory">Working directory for the command.</param>
/// <param name="requiresElevation">Whether elevation is required.</param>
/// <param name="installationInstructions">The installation instructions object.</param>
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder AddPreInstallStep(string name, string command, List<string>? arguments = null, string workingDirectory = "", bool requiresElevation = false);
IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions);

/// <summary>
/// Adds a post-installation step.
/// </summary>
/// <param name="name">Step name.</param>
/// <param name="command">Command to execute.</param>
/// <param name="arguments">Command arguments.</param>
/// <param name="workingDirectory">Working directory for the command.</param>
/// <param name="kind">The kind of installation step to execute.</param>
/// <param name="targetRelativePath">Target relative path within workspace.</param>
/// <param name="arguments">Command arguments for executable steps.</param>
/// <param name="destinationRelativePath">Destination relative path for rename operations.</param>
/// <param name="requiresElevation">Whether elevation is required.</param>
/// <param name="statusMessage">Optional user-facing status message.</param>
/// <param name="runOnce">Whether to execute only once and skip on future updates.</param>
/// <param name="stepKey">Optional unique step key for tracking execution.</param>
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder AddPostInstallStep(string name, string command, List<string>? arguments = null, string workingDirectory = "", bool requiresElevation = false);
IContentManifestBuilder AddPostInstallStep(
string name,
InstallationStepKind kind,
string? targetRelativePath = null,
List<string>? arguments = null,
string? destinationRelativePath = null,
bool requiresElevation = false,
string? statusMessage = null,
bool runOnce = false,
string? stepKey = null);

/// <summary>
/// Adds a post-installation step using an existing <see cref="InstallationStep"/> instance.
/// </summary>
/// <param name="step">The installation step to add.</param>
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder AddPostInstallStep(InstallationStep step);

/// <summary>
/// Adds a content reference for cross-publisher linking.
Expand All @@ -244,6 +267,13 @@ IContentManifestBuilder AddContentReference(
string minVersion = "",
string maxVersion = "");

/// <summary>
/// Sets content references for cross-publisher linking.
/// </summary>
/// <param name="contentReferences">The collection of content references.</param>
/// <returns>The builder instance for chaining.</returns>
IContentManifestBuilder WithContentReferences(IEnumerable<ContentReference> contentReferences);

/// <summary>
/// Adds a file patching operation to the manifest.
/// </summary>
Expand Down
29 changes: 29 additions & 0 deletions GenHub/GenHub.Core/Models/Common/UserSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,41 @@ public class UserSettings
/// </summary>
public CasConfiguration CasConfiguration { get; set; } = new();

/// <summary>
/// Gets or sets the collection of installation step keys that have been executed on this machine.
/// </summary>
public HashSet<string> ExecutedInstallationSteps { get; set; } = [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

/// <summary>Marks a property as explicitly set by the user.</summary>
/// <param name="propertyName">The name of the property to mark as explicitly set.</param>
public void MarkAsExplicitlySet(string propertyName)
{
ExplicitlySetProperties.Add(propertyName);
}

/// <summary>
/// Checks whether an installation step key has already been recorded as executed.
/// </summary>
/// <param name="stepKey">The unique installation step key.</param>
/// <returns><see langword="true"/> if already executed; otherwise, <see langword="false"/>.</returns>
public bool IsInstallationStepExecuted(string stepKey)
{
return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey);
}

/// <summary>
/// Records that an installation step key has been executed.
/// </summary>
/// <param name="stepKey">The unique installation step key.</param>
public void RecordInstallationStepExecuted(string stepKey)
{
if (!string.IsNullOrWhiteSpace(stepKey))
{
ExecutedInstallationSteps ??= [];
ExecutedInstallationSteps.Add(stepKey);
Comment thread
undead2146 marked this conversation as resolved.
}
}

/// <summary>Checks if a property was explicitly set by the user.</summary>
/// <param name="propertyName">The name of the property to check.</param>
/// <returns><c>true</c> if the property was explicitly set by the user; otherwise, <c>false</c>.</returns>
Expand Down Expand Up @@ -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<string, string>(SkippedUpdateVersions) : [],
PreferredUpdateStrategy = PreferredUpdateStrategy,
PublisherSubscriptions = PublisherSubscriptions != null
Expand Down
Loading
Loading