From 289d42722f02623b4b5acb7496708707dc6735fc Mon Sep 17 00:00:00 2001
From: Undead <110314402+undead2146@users.noreply.github.com>
Date: Wed, 19 Aug 2026 08:59:26 +0200
Subject: [PATCH 1/6] fix: enable executable selection for GameClient,
ModdingTool, and Executable in Add Local Content dialog (#391)
---
.../Content/ILocalContentService.cs | 8 +-
.../Manifest/ManifestVariantResolver.cs | 8 +-
.../Services/Content/LocalContentService.cs | 31 +-
.../Services/LocalContentServiceTests.cs | 286 +++++++
.../AddLocalContentViewModelTests.cs | 799 ++++++++++++++++++
.../Services/ProfileLauncherFacade.cs | 297 ++++---
.../ViewModels/AddLocalContentViewModel.cs | 236 +++++-
.../DemoAddLocalContentViewModel.cs | 1 +
.../GameProfiles/ViewModels/FileTreeItem.cs | 12 +-
.../GameProfileSettingsViewModel.Commands.cs | 4 +-
.../Views/AddLocalContentView.axaml | 50 +-
.../Views/AddLocalContentView.axaml.cs | 7 +
.../Views/AddLocalContentWindow.axaml.cs | 7 +
.../Info/Services/MockToolServices.cs | 29 +-
.../ExecutableHighlightConverter.cs | 6 +-
15 files changed, 1611 insertions(+), 170 deletions(-)
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
index 84a4d3773..9b26f26b2 100644
--- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
@@ -20,6 +20,7 @@ public interface ILocalContentService
/// Optional original source path of the content.
/// Optional progress reporter for tracking manifest creation.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the created manifest or errors.
Task> CreateLocalContentManifestAsync(
string directoryPath,
@@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Adds local content by creating and storing a manifest.
@@ -66,6 +68,7 @@ Task> AddLocalContentAsync(
/// Optional original source path of the content.
/// Optional progress reporter.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the updated manifest.
Task> UpdateLocalContentManifestAsync(
string existingManifestId,
@@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Gets the allowed content types for local content creation.
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
index ddb22794a..fc609db20 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
@@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint(
files);
}
- private static bool PathsMatch(string left, string right) =>
+ ///
+ /// Determines whether two relative file paths match, normalizing directory separators and leading slashes.
+ ///
+ /// The first relative path.
+ /// The second relative path.
+ /// true if the paths match; otherwise, false.
+ public static bool PathsMatch(string left, string right) =>
string.Equals(
left.Replace('\\', '/').TrimStart('/'),
right.Replace('\\', '/').TrimStart('/'),
diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
index 8f544eec5..2912aa9a5 100644
--- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
+++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
@@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
@@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs
var manifest = builder.Build();
manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath;
+ if (!string.IsNullOrWhiteSpace(entryPoint))
+ {
+ var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/');
+
+ var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == ".."))
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..').");
+ }
+
+ var matchedFile = manifest.Files.FirstOrDefault(f =>
+ ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint));
+
+ if (matchedFile == null)
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' was not found among the files in the directory.");
+ }
+
+ manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/');
+ }
+
// Auto-add GameInstallation dependency for GameClient content types
// This ensures auto-resolution logic works correctly for locally added clients
if (contentType == ContentType.GameClient)
@@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
// 1. Create the new manifest/content
// We do this FIRST to ensure the new content is valid before deleting the old one
- var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken);
+ var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint);
if (!createResult.Success)
{
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs
new file mode 100644
index 000000000..d8f2d1692
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs
@@ -0,0 +1,286 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Core.Services.Content;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services;
+
+///
+/// Contains tests for .
+///
+public class LocalContentServiceTests : IDisposable
+{
+ private readonly Mock _manifestGenServiceMock;
+ private readonly Mock _contentStorageServiceMock;
+ private readonly Mock _reconciliationServiceMock;
+ private readonly LocalContentService _service;
+ private readonly string _tempDir;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LocalContentServiceTests()
+ {
+ _manifestGenServiceMock = new Mock();
+ _contentStorageServiceMock = new Mock();
+ _reconciliationServiceMock = new Mock();
+
+ _service = new LocalContentService(
+ _manifestGenServiceMock.Object,
+ _contentStorageServiceMock.Object,
+ _reconciliationServiceMock.Object,
+ NullLogger.Instance);
+
+ _tempDir = Path.Combine(Path.GetTempPath(), "LocalContentServiceTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ ///
+ /// Cleans up temporary resources.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup failures
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync sets EntryPoint when provided.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithEntryPoint_SetsManifestEntryPoint()
+ {
+ SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "FinalBIG",
+ contentType: ContentType.ModdingTool,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "FinalBIG.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("FinalBIG.exe", result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync normalizes backslashes to forward slashes in EntryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_NormalizesBackslashesInEntryPoint()
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "bin/sub/tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "bin\\sub\\tool.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("bin/sub/tool.exe", result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when passed a whitespace-only value.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithWhitespaceOnlyEntryPoint_LeavesEntryPointNull()
+ {
+ SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "FinalBIG",
+ contentType: ContentType.ModdingTool,
+ targetGame: GameType.ZeroHour,
+ entryPoint: " ");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Null(result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync rejects rooted or parent-traversal entry points.
+ ///
+ /// The invalid entry point path to test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData("/usr/bin/tool.exe")]
+ [InlineData("../tool.exe")]
+ [InlineData("bin/../../tool.exe")]
+ public async Task CreateLocalContentManifestAsync_WithInvalidEntryPointPath_ReturnsFailure(string invalidEntryPoint)
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: invalidEntryPoint);
+
+ Assert.False(result.Success);
+ Assert.Contains("invalid", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync accepts entry points with double dots in file or folder names.
+ ///
+ /// The valid entry point path with dots in name.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData("game..exe")]
+ [InlineData("backup..old/tool.exe")]
+ public async Task CreateLocalContentManifestAsync_WithDoubleDotsInName_ReturnsSuccess(string validEntryPoint)
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", validEntryPoint);
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: validEntryPoint);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal(validEntryPoint, result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync rejects an entry point that does not exist in manifest files.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithNonExistentEntryPoint_ReturnsFailure()
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "missing.exe");
+
+ Assert.False(result.Success);
+ Assert.Contains("not found", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when not provided.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithoutEntryPoint_LeavesEntryPointNull()
+ {
+ SetupManifestBuilder(ContentType.Mod, GameType.ZeroHour, "MyMod");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "MyMod",
+ contentType: ContentType.Mod,
+ targetGame: GameType.ZeroHour);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Null(result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that UpdateLocalContentManifestAsync passes entryPoint through to the created manifest.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task UpdateLocalContentManifestAsync_WithEntryPoint_SetsEntryPointOnUpdatedManifest()
+ {
+ SetupManifestBuilder(ContentType.GameClient, GameType.ZeroHour, "GeneralsClient", "generals.exe");
+
+ _reconciliationServiceMock
+ .Setup(x => x.OrchestrateLocalUpdateAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentUpdateResult()));
+
+ var result = await _service.UpdateLocalContentManifestAsync(
+ existingManifestId: "1.0.local.gameclient.old",
+ name: "GeneralsClient",
+ directoryPath: _tempDir,
+ contentType: ContentType.GameClient,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "generals.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("generals.exe", result.Data!.EntryPoint);
+ }
+
+ private void SetupManifestBuilder(ContentType contentType, GameType targetGame, string contentName, params string[] filePaths)
+ {
+ var files = filePaths.Length > 0
+ ? filePaths.Select(f => new ManifestFile { RelativePath = f, IsExecutable = f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) }).ToList()
+ : new List();
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create($"1.0.local.{contentType.ToString().ToLowerInvariant()}.{contentName.ToLowerInvariant()}"),
+ Name = contentName,
+ ContentType = contentType,
+ TargetGame = targetGame,
+ Files = files,
+ };
+
+ var builderMock = new Mock();
+ builderMock.Setup(b => b.Build()).Returns(manifest);
+
+ _manifestGenServiceMock
+ .Setup(x => x.CreateContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(builderMock.Object);
+
+ _contentStorageServiceMock
+ .Setup(x => x.StoreContentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(manifest));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs
new file mode 100644
index 000000000..c7490ef60
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs
@@ -0,0 +1,799 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.Common;
+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.Features.GameProfiles.ViewModels;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels;
+
+///
+/// Contains tests for .
+///
+public class AddLocalContentViewModelTests : IDisposable
+{
+ private readonly Mock _localContentServiceMock;
+ private readonly Mock _contentStorageServiceMock;
+ private readonly Mock _normalizationServiceMock;
+ private readonly Mock _dialogServiceMock;
+ private readonly List _tempDirectories = [];
+ private readonly List _viewModels = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AddLocalContentViewModelTests()
+ {
+ _localContentServiceMock = new Mock();
+ _contentStorageServiceMock = new Mock();
+ _normalizationServiceMock = new Mock();
+ _dialogServiceMock = new Mock();
+
+ _localContentServiceMock
+ .Setup(x => x.AllowedContentTypes)
+ .Returns(AddLocalContentViewModel.AllowedContentTypes);
+
+ _normalizationServiceMock
+ .Setup(x => x.DetectGenLauncherFilesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new GenLauncherDetectionResult());
+ }
+
+ ///
+ /// Cleans up temporary test directories and viewmodels.
+ ///
+ public void Dispose()
+ {
+ foreach (var vm in _viewModels)
+ {
+ vm.Dispose();
+ }
+
+ foreach (var dir in _tempDirectories)
+ {
+ try
+ {
+ if (Directory.Exists(dir))
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Verifies that the ViewModel initializes with proper defaults.
+ ///
+ [Fact]
+ public void Constructor_InitializesWithDefaultValues()
+ {
+ var vm = CreateViewModel();
+
+ Assert.NotNull(vm);
+ Assert.Equal(ContentType.Mod, vm.SelectedContentType);
+ Assert.Equal(GameType.ZeroHour, vm.SelectedGameType);
+ Assert.Empty(vm.ContentName);
+ Assert.Empty(vm.SourcePath);
+ Assert.Empty(vm.FileTree);
+ Assert.False(vm.IsEditing);
+ Assert.False(vm.CanAdd);
+ Assert.False(vm.ShowExecutableSelection);
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.Equal(0, vm.ExecutableCount);
+ Assert.Equal("Add Local Content", vm.DialogTitle);
+ Assert.Equal("Add to Library", vm.ActionButtonText);
+ Assert.Contains(ContentType.GameClient, AddLocalContentViewModel.AllowedContentTypes);
+ Assert.Contains(ContentType.ModdingTool, AddLocalContentViewModel.AllowedContentTypes);
+ Assert.Contains(ContentType.Executable, AddLocalContentViewModel.AllowedContentTypes);
+ }
+
+ ///
+ /// Verifies that PreviewIdleText changes based on SelectedContentType.
+ ///
+ /// The content type under test.
+ /// The expected idle description text.
+ [Theory]
+ [InlineData(ContentType.Mod, "Import mod content (e.g. .big, .zip)")]
+ [InlineData(ContentType.GameClient, "Import GameClient")]
+ [InlineData(ContentType.Executable, "Import executable")]
+ [InlineData(ContentType.ModdingTool, "Import tool executable")]
+ [InlineData(ContentType.Patch, "Import patch")]
+ [InlineData(ContentType.Addon, "Import addon content")]
+ [InlineData(ContentType.Map, "Import map files")]
+ [InlineData(ContentType.MapPack, "Import map pack files")]
+ [InlineData(ContentType.Mission, "Import mission content")]
+ public void PreviewIdleText_ReturnsExpectedDescriptions(ContentType type, string expectedText)
+ {
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+
+ Assert.Equal(expectedText, vm.PreviewIdleText);
+ }
+
+ ///
+ /// Verifies that ShowExecutableSelection is true when ExecutableCount > 0 for GameClient, ModdingTool, and Executable.
+ ///
+ /// The content type under test.
+ /// The number of detected executables.
+ /// The expected boolean indicating whether executable selection is shown.
+ [Theory]
+ [InlineData(ContentType.GameClient, 1, true)]
+ [InlineData(ContentType.GameClient, 2, true)]
+ [InlineData(ContentType.ModdingTool, 1, true)]
+ [InlineData(ContentType.ModdingTool, 2, true)]
+ [InlineData(ContentType.Executable, 1, true)]
+ [InlineData(ContentType.Executable, 2, true)]
+ [InlineData(ContentType.GameClient, 0, false)]
+ [InlineData(ContentType.ModdingTool, 0, false)]
+ [InlineData(ContentType.Executable, 0, false)]
+ [InlineData(ContentType.Mod, 1, false)]
+ [InlineData(ContentType.Mod, 2, false)]
+ [InlineData(ContentType.Patch, 1, false)]
+ [InlineData(ContentType.Map, 1, false)]
+ public void ShowExecutableSelection_EvaluatesCorrectly_BasedOnContentTypeAndExecutableCount(
+ ContentType contentType,
+ int executableCount,
+ bool expectedShow)
+ {
+ var vm = CreateViewModel();
+ vm.SelectedContentType = contentType;
+ vm.ExecutableCount = executableCount;
+
+ Assert.Equal(expectedShow, vm.ShowExecutableSelection);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for GameClient.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForGameClient_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "generals.exe");
+ var dataPath = Path.Combine(tempDir, "data.ini");
+ File.WriteAllText(exePath, "fake-exe-content");
+ File.WriteAllText(dataPath, "fake-data");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("generals.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for ModdingTool.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForModdingTool_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "FinalBIG.exe");
+ var dataPath = Path.Combine(tempDir, "readme.txt");
+ File.WriteAllText(exePath, "fake-exe-content");
+ File.WriteAllText(dataPath, "read me");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("FinalBIG.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for Executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForExecutable_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "WorldBuilder.exe");
+ File.WriteAllText(exePath, "fake-exe-content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Executable;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("WorldBuilder.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that switching to an executable content type triggers auto-selection if an executable is in the tree.
+ ///
+ /// The executable content type to switch to.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task SelectedContentTypeChanged_ToExecutableType_AutoSelectsFirstExecutable(ContentType newType)
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Launcher.exe");
+ File.WriteAllText(exePath, "fake-exe-content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Mod;
+
+ await vm.ImportContentAsync(tempDir);
+
+ // When imported as Mod, no auto-selection happened
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.False(vm.ShowExecutableSelection);
+
+ // Switch to executable type
+ vm.SelectedContentType = newType;
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("Launcher.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ Assert.True(vm.ShowExecutableSelection);
+ }
+
+ ///
+ /// Verifies manual selection of an executable via SelectExecutableCommand.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SelectExecutableCommand_SwitchesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exe1Path = Path.Combine(tempDir, "Primary.exe");
+ var exe2Path = Path.Combine(tempDir, "Secondary.exe");
+ File.WriteAllText(exe1Path, "fake-exe-1");
+ File.WriteAllText(exe2Path, "fake-exe-2");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(2, vm.ExecutableCount);
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ var initialSelected = vm.SelectedExecutableItem!;
+ var otherItem = FindInTree(vm.FileTree, f => f != initialSelected && f.IsExecutable);
+ Assert.NotNull(otherItem);
+ Assert.False(otherItem!.IsSelectedExecutable);
+
+ // Select the other executable
+ vm.SelectExecutableCommand.Execute(otherItem);
+
+ Assert.Equal(otherItem.Name, vm.SelectedExecutableItem.Name);
+ Assert.True(otherItem.IsSelectedExecutable);
+ Assert.False(initialSelected.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that SelectExecutableCommand ignores non-executable files.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SelectExecutableCommand_IgnoresNonExecutableItem()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Tool.exe");
+ var txtPath = Path.Combine(tempDir, "Doc.txt");
+ File.WriteAllText(exePath, "fake-exe");
+ File.WriteAllText(txtPath, "text");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Executable;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name);
+
+ var txtItem = FindInTree(vm.FileTree, f => f.Name == "Doc.txt");
+ Assert.NotNull(txtItem);
+ Assert.False(txtItem!.IsExecutable);
+
+ vm.SelectExecutableCommand.Execute(txtItem);
+
+ // Should still be Tool.exe
+ Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name);
+ Assert.False(txtItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that CanAdd validation requires an executable for GameClient, ModdingTool, and Executable.
+ ///
+ /// The executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task Validation_CanAdd_RequiresExecutable_ForExecutableTypes(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var txtPath = Path.Combine(tempDir, "config.ini");
+ File.WriteAllText(txtPath, "config");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Tool";
+
+ await vm.ImportContentAsync(tempDir);
+
+ // No executable found, so CanAdd should be false
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.False(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that CanAdd is true for non-executable types without an executable.
+ ///
+ /// The non-executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.Mod)]
+ [InlineData(ContentType.Patch)]
+ [InlineData(ContentType.Addon)]
+ [InlineData(ContentType.Map)]
+ [InlineData(ContentType.MapPack)]
+ [InlineData(ContentType.Mission)]
+ public async Task Validation_CanAdd_DoesNotRequireExecutable_ForNonExecutableTypes(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var txtPath = Path.Combine(tempDir, "mod_data.big");
+ File.WriteAllText(txtPath, "big archive data");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Mod";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.True(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that CanAdd is true when an executable is present for GameClient, ModdingTool, and Executable.
+ ///
+ /// The executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task Validation_CanAdd_IsTrue_WhenExecutableIsPresent(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Main.exe");
+ File.WriteAllText(exePath, "exe content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Item";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.True(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that AddContentCommand forwards the relative entry point to ILocalContentService.CreateLocalContentManifestAsync.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_PassesEntryPoint_ToCreateLocalContentManifestAsync()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Game.exe");
+ File.WriteAllText(exePath, "exe");
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.gameclient.test"),
+ Name = "Test Game Client",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ EntryPoint = "Game.exe",
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Game Client";
+
+ // Import individual file so it lands at the root of staging
+ await vm.ImportContentAsync(exePath);
+
+ Assert.True(vm.CanAdd);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ Assert.Equal("Game.exe", capturedEntryPoint);
+ Assert.NotNull(vm.CreatedContentItem);
+ }
+
+ ///
+ /// Verifies that AddContentCommand with nested executable passes correct relative path as entryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_WithNestedExecutable_PassesRelativePathEntryPoint()
+ {
+ var tempDir = CreateTempDirectory();
+ var subDir = Path.Combine(tempDir, "bin");
+ Directory.CreateDirectory(subDir);
+ var exePath = Path.Combine(subDir, "tool.exe");
+ File.WriteAllText(exePath, "tool exe");
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.moddingtool.tool"),
+ Name = "My Tool",
+ ContentType = ContentType.ModdingTool,
+ TargetGame = GameType.ZeroHour,
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+ vm.ContentName = "My Tool";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ var dirName = Path.GetFileName(tempDir);
+ Assert.Equal($"{dirName}/bin/tool.exe", capturedEntryPoint);
+ }
+
+ ///
+ /// Verifies that LoadFromManifestAsync preserves the manifest EntryPoint when reloading for edit.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task LoadFromManifestAsync_PreservesManifestEntryPoint()
+ {
+ var manifestId = ManifestId.Create("1.0.local.gameclient.zh");
+
+ var manifest = new ContentManifest
+ {
+ Id = manifestId,
+ Name = "ZH Client",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ EntryPoint = "special.exe",
+ Files =
+ [
+ new ManifestFile { RelativePath = "special.exe", IsExecutable = true },
+ new ManifestFile { RelativePath = "bin/decoy.exe", IsExecutable = true },
+ ],
+ };
+
+ _contentStorageServiceMock
+ .Setup(x => x.RetrieveContentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((_, targetPath, _) =>
+ {
+ Directory.CreateDirectory(targetPath);
+ File.WriteAllText(Path.Combine(targetPath, "special.exe"), "exe");
+ var targetSub = Path.Combine(targetPath, "bin");
+ Directory.CreateDirectory(targetSub);
+ File.WriteAllText(Path.Combine(targetSub, "decoy.exe"), "decoy");
+ })
+ .ReturnsAsync((ManifestId _, string targetPath, CancellationToken _) => OperationResult.CreateSuccess(targetPath));
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.UpdateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(manifest));
+
+ var item = new GenHub.Features.GameProfiles.ViewModels.ContentDisplayItem
+ {
+ Id = manifestId.Value,
+ ManifestId = manifestId,
+ DisplayName = "ZH Client",
+ ContentType = ContentType.GameClient,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Unknown,
+ Manifest = manifest,
+ };
+
+ var vm = CreateViewModel();
+ await vm.LoadFromManifestAsync(item);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("special.exe", vm.SelectedExecutableItem.Name);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+ Assert.Equal("special.exe", capturedEntryPoint);
+ }
+
+ ///
+ /// Verifies that deleting an unrelated item preserves the previously selected executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task DeleteItemAsync_PreservesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+ File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ var readme = FindInTree(vm.FileTree, f => f.Name == "readme.txt");
+ Assert.NotNull(readme);
+ await vm.DeleteItemCommand.ExecuteAsync(readme);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that deleting the currently selected executable falls back to auto-selecting the remaining executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task DeleteItemAsync_WhenSelectedExecutableDeleted_FallsBackToRemainingExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+ File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ await vm.DeleteItemCommand.ExecuteAsync(secondExe);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("first.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that switching content type away from executable and back preserves the selected entry point.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ContentTypeChanged_SwitchAwayAndBack_PreservesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ // Switch to Mod (non-executable type)
+ vm.SelectedContentType = ContentType.Mod;
+ Assert.Null(vm.SelectedExecutableItem);
+
+ // Switch back to GameClient (executable type)
+ vm.SelectedContentType = ContentType.GameClient;
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that BuildDirectoryTree prioritizes directories containing executables over non-executable directories.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task BuildDirectoryTree_PrioritizesDirectoriesWithExecutables()
+ {
+ var tempDir = CreateTempDirectory();
+
+ // Create 25 directories named folder01 to folder25
+ for (var i = 1; i <= 25; i++)
+ {
+ var folder = Path.Combine(tempDir, $"folder{i:D2}");
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "data.txt"), "content");
+ }
+
+ // Put an executable only in the 25th folder
+ var targetFolder = Path.Combine(tempDir, "folder25");
+ File.WriteAllText(Path.Combine(targetFolder, "game.exe"), "executable");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var folder25 = FindInTree(vm.FileTree, f => f.Name == "folder25");
+ Assert.NotNull(folder25);
+
+ var exe = FindInTree(folder25.Children, f => f.Name == "game.exe");
+ Assert.NotNull(exe);
+ Assert.True(exe.IsExecutable);
+ }
+
+ ///
+ /// Verifies that switching from an executable type to a non-executable type clears the selected executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ContentTypeChanged_FromExecutableToNonExecutable_ClearsSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "game.exe"), "game");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ vm.SelectedContentType = ContentType.Mod;
+
+ Assert.Null(vm.SelectedExecutableItem);
+ }
+
+ ///
+ /// Verifies that AddContentCommand with non-executable content type passes null as entryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_WhenNonExecutableType_PassesNullEntryPoint()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "somefile.txt"), "text");
+
+ string? capturedEntryPoint = "INITIAL";
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.mod.test"),
+ Name = "My Mod",
+ ContentType = ContentType.Mod,
+ TargetGame = GameType.ZeroHour,
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Mod;
+ vm.ContentName = "My Mod";
+ await vm.ImportContentAsync(tempDir);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ Assert.Null(capturedEntryPoint);
+ }
+
+ private static FileTreeItem? FindInTree(IEnumerable items, Func predicate)
+ {
+ foreach (var item in items)
+ {
+ if (predicate(item)) return item;
+ var child = FindInTree(item.Children, predicate);
+ if (child != null) return child;
+ }
+
+ return null;
+ }
+
+ private string CreateTempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "AddLocalContentVmTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ _tempDirectories.Add(path);
+ return path;
+ }
+
+ private AddLocalContentViewModel CreateViewModel()
+ {
+ var vm = new AddLocalContentViewModel(
+ _localContentServiceMock.Object,
+ _contentStorageServiceMock.Object,
+ _normalizationServiceMock.Object,
+ _dialogServiceMock.Object,
+ NullLogger.Instance);
+ _viewModels.Add(vm);
+ return vm;
+ }
+}
diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs
index 2ffca7e4c..7e36d6669 100644
--- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs
@@ -451,99 +451,25 @@ private async Task> LaunchToolProfileAsyn
{
logger.LogInformation("[Launch] Detected Tool profile, launching tool directly");
- // Get the tool manifest
- if (string.IsNullOrWhiteSpace(profile.ToolContentId))
- {
- return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId);
- }
-
- if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId))
- {
- return ProfileOperationResult.CreateFailure(
- $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}");
- }
-
- var toolManifestResult = await manifestPool.GetManifestAsync(
- toolManifestId,
- cancellationToken);
-
- if (toolManifestResult.Failed || toolManifestResult.Data == null)
+ var manifestResult = await ResolveToolManifestAsync(profile, cancellationToken);
+ if (manifestResult.Failed || manifestResult.Data == null)
{
return ProfileOperationResult.CreateFailure(
- $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}");
+ manifestResult.FirstError ?? ProfileValidationConstants.FailedToLoadToolManifest);
}
- var toolManifest = toolManifestResult.Data;
+ var toolManifest = manifestResult.Data;
logger.LogDebug("[Launch] Tool manifest loaded: {ManifestId}", toolManifest.Id);
- var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken);
- string toolWorkspacePath = string.Empty;
- string? actualWorkspaceId = null;
-
- if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data))
+ var workspaceResult = await ResolveToolWorkspaceAsync(profile, toolManifest, cancellationToken);
+ if (workspaceResult.Failed)
{
- toolWorkspacePath = toolDirectory.Data;
- logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolWorkspacePath);
- }
- else
- {
- logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager");
-
- var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient
- {
- Name = toolManifest.Name,
- GameType = toolManifest.TargetGame,
- };
-
- var appDataBase = configurationProvider.GetApplicationDataPath();
- if (!Directory.Exists(appDataBase))
- {
- Directory.CreateDirectory(appDataBase);
- }
-
- var baseDetails = appDataBase;
-
- var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken);
- var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest];
-
- var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy();
- var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy);
-
- if (effectiveToolStrategy != requestedToolStrategy)
- {
- logger.LogInformation(
- "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment",
- requestedToolStrategy);
- }
-
- actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}";
- var workspaceConfig = new WorkspaceConfiguration
- {
- Id = actualWorkspaceId,
- Manifests = [.. allManifests],
- GameClient = dummyGameClient,
- Strategy = effectiveToolStrategy,
- ForceRecreate = false,
- ValidateAfterPreparation = true,
- BaseInstallationPath = baseDetails,
- WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces),
- SkipCleanup = false,
- };
-
- var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken);
- if (prepareResult.Failed)
- {
- return ProfileOperationResult.CreateFailure(
- $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}");
- }
-
- toolWorkspacePath = prepareResult.Data!.WorkspacePath;
- logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath);
+ return ProfileOperationResult.CreateFailure(
+ workspaceResult.FirstError ?? ProfileValidationConstants.FailedToPrepareToolWorkspace);
}
- var toolDirectoryPath = toolWorkspacePath;
- var toolExecutable = toolManifest.Files?.FirstOrDefault(f => f.IsExecutable)
- ?? toolManifest.Files?.FirstOrDefault(f => f.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
+ var (toolDirectoryPath, actualWorkspaceId) = workspaceResult.Data;
+ var toolExecutable = ResolveToolExecutable(toolManifest);
if (toolExecutable == null)
{
@@ -564,35 +490,7 @@ private async Task> LaunchToolProfileAsyn
try
{
- var processStartInfo = new ProcessStartInfo
- {
- FileName = toolExecutablePath,
- WorkingDirectory = toolDirectoryPath,
- Arguments = profile.CommandLineArguments ?? string.Empty,
- UseShellExecute = false,
- };
-
- if (profile.EnvironmentVariables != null)
- {
- foreach (var envVar in profile.EnvironmentVariables)
- {
- processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value;
- }
- }
-
- Process? process = null;
- try
- {
- process = Process.Start(processStartInfo);
- }
- catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740)
- {
- logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored.");
- processStartInfo.UseShellExecute = true;
- processStartInfo.Verb = "runas";
- process = Process.Start(processStartInfo);
- }
-
+ var process = StartToolProcess(toolExecutablePath, toolDirectoryPath, profile);
if (process == null)
{
return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProcessStartFailed);
@@ -631,12 +529,181 @@ private async Task> LaunchToolProfileAsyn
}
catch (Exception ex)
{
- logger.LogError(ex, "[Launch] Tool launch failed");
+ logger.LogError(ex, "[Launch] Unexpected error launching tool for profile {ProfileId}", profileId);
notificationService.ShowError(
ProfileValidationConstants.ToolLaunchFailedTitle,
$"Failed to launch '{profile.Name}': {ex.Message}",
NotificationDurations.VeryLong);
- return ProfileOperationResult.CreateFailure($"Tool launch failed: {ex.Message}");
+ return ProfileOperationResult.CreateFailure(
+ $"Tool launch failed: {ex.Message}");
+ }
+ }
+
+ private async Task> ResolveToolManifestAsync(
+ GameProfile profile,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(profile.ToolContentId))
+ {
+ return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId);
+ }
+
+ if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId))
+ {
+ return ProfileOperationResult.CreateFailure(
+ $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}");
+ }
+
+ var toolManifestResult = await manifestPool.GetManifestAsync(
+ toolManifestId,
+ cancellationToken);
+
+ if (toolManifestResult.Failed || toolManifestResult.Data == null)
+ {
+ return ProfileOperationResult.CreateFailure(
+ $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}");
+ }
+
+ return ProfileOperationResult.CreateSuccess(toolManifestResult.Data);
+ }
+
+ private async Task> ResolveToolWorkspaceAsync(
+ GameProfile profile,
+ ContentManifest toolManifest,
+ CancellationToken cancellationToken)
+ {
+ var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken);
+ if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data))
+ {
+ logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolDirectory.Data);
+ return ProfileOperationResult<(string, string?)>.CreateSuccess((toolDirectory.Data, null));
+ }
+
+ logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager");
+
+ var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient
+ {
+ Name = toolManifest.Name,
+ GameType = toolManifest.TargetGame,
+ };
+
+ var appDataBase = configurationProvider.GetApplicationDataPath();
+ if (!Directory.Exists(appDataBase))
+ {
+ Directory.CreateDirectory(appDataBase);
+ }
+
+ var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken);
+ var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest];
+
+ var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy();
+ var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy);
+
+ if (effectiveToolStrategy != requestedToolStrategy)
+ {
+ logger.LogInformation(
+ "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment",
+ requestedToolStrategy);
+ }
+
+ var actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}";
+ var workspaceConfig = new WorkspaceConfiguration
+ {
+ Id = actualWorkspaceId,
+ Manifests = [.. allManifests],
+ GameClient = dummyGameClient,
+ Strategy = effectiveToolStrategy,
+ ForceRecreate = false,
+ ValidateAfterPreparation = true,
+ BaseInstallationPath = appDataBase,
+ WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces),
+ SkipCleanup = false,
+ };
+
+ var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken);
+ if (prepareResult.Failed)
+ {
+ return ProfileOperationResult<(string, string?)>.CreateFailure(
+ $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}");
+ }
+
+ var toolWorkspacePath = prepareResult.Data!.WorkspacePath;
+ logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath);
+ return ProfileOperationResult<(string, string?)>.CreateSuccess((toolWorkspacePath, actualWorkspaceId));
+ }
+
+ private ManifestFile? ResolveToolExecutable(ContentManifest toolManifest)
+ {
+ var resolvedFiles = ManifestVariantResolver.ResolveFiles(toolManifest);
+ var resolution = ManifestVariantResolver.ResolveEntryPoint(toolManifest);
+
+ if (resolution.Success && resolution.RelativePath != null)
+ {
+ var toolExecutable = resolvedFiles?.FirstOrDefault(f =>
+ ManifestVariantResolver.PathsMatch(f.RelativePath, resolution.RelativePath));
+
+ if (toolExecutable != null)
+ {
+ logger.LogInformation(
+ "[Launch] Tool executable resolved for manifest {ManifestId}: {RelativePath} ({Reason})",
+ toolManifest.Id,
+ toolExecutable.RelativePath,
+ resolution.Reason);
+ }
+ else
+ {
+ logger.LogWarning(
+ "[Launch] Entry point '{RelativePath}' resolved for tool manifest {ManifestId} ({Reason}) but not found in resolved files",
+ resolution.RelativePath,
+ toolManifest.Id,
+ resolution.Reason);
+ }
+
+ return toolExecutable;
+ }
+
+ logger.LogWarning(
+ "[Launch] Entry point resolution for tool manifest '{ManifestId}' did not succeed: {Resolution}",
+ toolManifest.Id,
+ resolution);
+
+ return null;
+ }
+
+ private Process? StartToolProcess(string toolExecutablePath, string toolDirectoryPath, GameProfile profile)
+ {
+ var processStartInfo = new ProcessStartInfo
+ {
+ FileName = toolExecutablePath,
+ WorkingDirectory = toolDirectoryPath,
+ Arguments = profile.CommandLineArguments ?? string.Empty,
+ UseShellExecute = false,
+ };
+
+ if (profile.EnvironmentVariables != null)
+ {
+ foreach (var envVar in profile.EnvironmentVariables)
+ {
+ processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value;
+ }
+ }
+
+ try
+ {
+ return Process.Start(processStartInfo);
+ }
+ catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740)
+ {
+ logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored.");
+ var elevatedStartInfo = new ProcessStartInfo
+ {
+ FileName = toolExecutablePath,
+ WorkingDirectory = toolDirectoryPath,
+ Arguments = profile.CommandLineArguments ?? string.Empty,
+ UseShellExecute = true,
+ Verb = "runas",
+ };
+ return Process.Start(elevatedStartInfo);
}
}
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs
index 55a28b999..386f01d9b 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs
@@ -12,6 +12,8 @@
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace GenHub.Features.GameProfiles.ViewModels;
@@ -29,7 +31,7 @@ public partial class AddLocalContentViewModel(
IContentStorageService? contentStorageService,
IGenLauncherNormalizationService? genLauncherNormalizationService,
IDialogService? dialogService,
- ILogger? logger = null) : ObservableObject
+ ILogger? logger = null) : ObservableObject, IDisposable
{
///
/// Gets the list of available game types.
@@ -56,6 +58,26 @@ public partial class AddLocalContentViewModel(
ContentType.Mission,
];
+ ///
+ /// Counts the total number of executables in the given file tree items recursively.
+ ///
+ /// The file tree items to inspect.
+ /// The total number of executable files found.
+ internal static int CountExecutables(IEnumerable items)
+ {
+ int count = 0;
+ foreach (var item in items)
+ {
+ if (item.IsExecutable) count++;
+ count += CountExecutables(item.Children);
+ }
+
+ return count;
+ }
+
+ private static bool RequiresExecutable(ContentType contentType) =>
+ contentType is ContentType.GameClient or ContentType.ModdingTool or ContentType.Executable;
+
private static FileTreeItem? FindFirstExecutable(IEnumerable items)
{
foreach (var item in items)
@@ -75,21 +97,10 @@ public partial class AddLocalContentViewModel(
return null;
}
- private static int CountExecutables(IEnumerable items)
- {
- int count = 0;
- foreach (var item in items)
- {
- if (item.IsExecutable) count++;
- count += CountExecutables(item.Children);
- }
-
- return count;
- }
-
private readonly string _stagingPath = Path.Combine(Path.GetTempPath(), "GenHub_Staging_" + Guid.NewGuid());
private string? _originalManifestId;
+ private string? _pendingEntryPoint;
///
/// Gets a value indicating whether we are editing existing content.
@@ -177,7 +188,7 @@ private static int CountExecutables(IEnumerable items)
private bool _isDemoMode;
///
- /// Gets or sets the selected executable item (for Executable/ModdingTool content type).
+ /// Gets or sets the selected executable item (for GameClient/Executable/ModdingTool content type).
///
[ObservableProperty]
private FileTreeItem? _selectedExecutableItem;
@@ -192,7 +203,7 @@ private static int CountExecutables(IEnumerable items)
///
/// Gets a value indicating whether the executable selection should be shown.
///
- public bool ShowExecutableSelection => (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) && ExecutableCount > 1;
+ public bool ShowExecutableSelection => RequiresExecutable(SelectedContentType) && ExecutableCount > 0;
///
/// Gets the text to display in the preview area when no content is loaded.
@@ -255,6 +266,7 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item)
StatusMessage = "Loading existing content...";
_originalManifestId = item.ManifestId.Value;
+ _pendingEntryPoint = item.Manifest?.EntryPoint;
ContentName = item.DisplayName ?? string.Empty;
SelectedContentType = item.ContentType;
SelectedGameType = item.GameType;
@@ -487,24 +499,81 @@ public async Task ImportContentAsync(string path)
}
}
+ ///
+ public void Dispose()
+ {
+ _cts?.Dispose();
+ _cts = null;
+ CleanupStaging();
+ GC.SuppressFinalize(this);
+ }
+
private static List BuildDirectoryTree(DirectoryInfo dir)
+ => BuildDirectoryTree(dir, CollectExecutableDirectories(dir));
+
+ private static HashSet CollectExecutableDirectories(DirectoryInfo root)
+ {
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ try
+ {
+ foreach (var file in root.EnumerateFiles("*", SearchOption.AllDirectories))
+ {
+ if (!ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(file.Name)
+ && !file.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ for (var d = file.Directory; d != null; d = d.Parent)
+ {
+ if (!result.Add(d.FullName))
+ {
+ break;
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore inaccessible directories
+ }
+
+ return result;
+ }
+
+ private static List BuildDirectoryTree(DirectoryInfo dir, HashSet executableDirs)
{
var items = new List();
- if (!dir.Exists) return items;
+ if (!dir.Exists)
+ {
+ return items;
+ }
+
+ var subDirs = dir.GetDirectories();
+ var prioritizedDirs = subDirs
+ .OrderByDescending(d => executableDirs.Contains(d.FullName))
+ .ThenBy(d => d.Name)
+ .Take(20);
- foreach (var d in dir.GetDirectories().Take(20))
+ foreach (var d in prioritizedDirs)
{
items.Add(new FileTreeItem
{
Name = d.Name,
IsFile = false,
FullPath = d.FullName,
- Children = new ObservableCollection(BuildDirectoryTree(d)),
+ Children = new ObservableCollection(BuildDirectoryTree(d, executableDirs)),
});
}
- foreach (var f in dir.GetFiles().Take(50))
+ var files = dir.GetFiles();
+ var prioritizedFiles = files
+ .OrderByDescending(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.Name) || f.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase))
+ .ThenBy(f => f.Name)
+ .Take(50);
+
+ foreach (var f in prioritizedFiles)
{
items.Add(new FileTreeItem { Name = f.Name, IsFile = true, FullPath = f.FullName });
}
@@ -640,6 +709,20 @@ private async Task AddContentAsync()
_cts = new CancellationTokenSource();
+ string? entryPoint = null;
+ if (RequiresExecutable(SelectedContentType) && SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath))
+ {
+ try
+ {
+ entryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/');
+ }
+ catch (Exception ex)
+ {
+ logger?.LogWarning(ex, "Failed to determine relative path for selected executable '{FullPath}'. Falling back to file name '{Name}'", SelectedExecutableItem.FullPath, SelectedExecutableItem.Name);
+ entryPoint = SelectedExecutableItem.Name;
+ }
+ }
+
// Preserve SourcePath metadata if available
// Note: We no longer write to "source.path" file to avoid polluting the content.
// Instead we pass the SourcePath directly to the service.
@@ -652,7 +735,8 @@ private async Task AddContentAsync()
targetGame,
SourcePath,
progress,
- _cts.Token)
+ _cts.Token,
+ entryPoint)
: await localContentService.CreateLocalContentManifestAsync(
_stagingPath,
ContentName,
@@ -660,7 +744,8 @@ private async Task AddContentAsync()
targetGame,
SourcePath,
progress,
- _cts.Token);
+ _cts.Token,
+ entryPoint);
if (result.Success)
{
@@ -770,6 +855,29 @@ private void CreateMapFoldersIfNeeded()
}
}
+ private FileTreeItem? FindFileItemByRelativePath(IEnumerable items, string relativePath)
+ {
+ var normalizedTarget = relativePath.Replace('\\', '/').TrimStart('/');
+ foreach (var item in items)
+ {
+ if (item.IsFile)
+ {
+ var itemRel = Path.GetRelativePath(_stagingPath, item.FullPath).Replace('\\', '/').TrimStart('/');
+ if (ManifestVariantResolver.PathsMatch(itemRel, normalizedTarget))
+ {
+ return item;
+ }
+ }
+ else
+ {
+ var found = FindFileItemByRelativePath(item.Children, relativePath);
+ if (found != null) return found;
+ }
+ }
+
+ return null;
+ }
+
private async Task RefreshStagingTreeAsync()
{
bool wasBusy = IsBusy;
@@ -777,6 +885,23 @@ private async Task RefreshStagingTreeAsync()
{
if (!wasBusy) IsBusy = true;
+ string? previousRelativePath = null;
+ if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath))
+ {
+ try
+ {
+ previousRelativePath = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/');
+ }
+ catch
+ {
+ // Ignore path calculation error
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(_pendingEntryPoint))
+ {
+ previousRelativePath = _pendingEntryPoint;
+ }
+
FileTree.Clear();
SelectedExecutableItem = null; // Clear previous selection on refresh
if (Directory.Exists(_stagingPath))
@@ -791,10 +916,29 @@ private async Task RefreshStagingTreeAsync()
ExecutableCount = CountExecutables(FileTree);
- // Auto-select first executable if content type requires it
- if (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable)
+ // Reselect previously selected executable or auto-select first if content type requires it
+ if (RequiresExecutable(SelectedContentType))
{
- AutoSelectFirstExecutable();
+ FileTreeItem? matchedItem = null;
+ if (!string.IsNullOrWhiteSpace(previousRelativePath))
+ {
+ matchedItem = FindFileItemByRelativePath(FileTree, previousRelativePath);
+ }
+
+ if (matchedItem != null && matchedItem.IsExecutable)
+ {
+ SelectedExecutableItem = matchedItem;
+ _pendingEntryPoint = null;
+ }
+ else
+ {
+ _pendingEntryPoint = null;
+ AutoSelectFirstExecutable();
+ }
+ }
+ else
+ {
+ SelectedExecutableItem = null;
}
Validate();
@@ -816,8 +960,8 @@ private void Validate()
var stagingExists = Directory.Exists(_stagingPath);
var stagingHasEntries = stagingExists && Directory.EnumerateFileSystemEntries(_stagingPath).Any();
- // For ModdingTool (Tool) and Executable, we also need an executable selected
- var requiresExecutable = SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable;
+ // For GameClient, ModdingTool (Tool), and Executable, we also need an executable selected
+ var requiresExecutable = RequiresExecutable(SelectedContentType);
var hasExecutableIfNeeded = !requiresExecutable || SelectedExecutableItem != null;
CanAdd = hasName && (hasFiles || stagingHasEntries) && hasExecutableIfNeeded;
@@ -842,10 +986,44 @@ partial void OnSelectedContentTypeChanged(ContentType value)
OnPropertyChanged(nameof(ShowExecutableSelection));
OnPropertyChanged(nameof(PreviewIdleText));
- // Auto-select first executable if switching to ModdingTool or Executable
- if ((value == ContentType.ModdingTool || value == ContentType.Executable) && SelectedExecutableItem == null)
+ // Auto-select first executable if switching to a content type that requires it,
+ // or clear selection when switching to a non-executable content type
+ if (RequiresExecutable(value))
+ {
+ if (SelectedExecutableItem == null)
+ {
+ FileTreeItem? matchedItem = null;
+ if (!string.IsNullOrWhiteSpace(_pendingEntryPoint))
+ {
+ matchedItem = FindFileItemByRelativePath(FileTree, _pendingEntryPoint);
+ }
+
+ if (matchedItem != null && matchedItem.IsExecutable)
+ {
+ SelectedExecutableItem = matchedItem;
+ _pendingEntryPoint = null;
+ }
+ else
+ {
+ AutoSelectFirstExecutable();
+ }
+ }
+ }
+ else
{
- AutoSelectFirstExecutable();
+ if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath))
+ {
+ try
+ {
+ _pendingEntryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/');
+ }
+ catch
+ {
+ // Ignore path calculation error
+ }
+ }
+
+ SelectedExecutableItem = null;
}
Validate();
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs
index afbd8961b..a22b52d3d 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs
@@ -147,6 +147,7 @@ private void InitializeDemoData()
};
FileTree.Add(modFolder);
+ ExecutableCount = CountExecutables(FileTree);
// Set status message
StatusMessage = "Demo content ready. Click buttons to see what they do!";
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs
index 20b12e965..81052c517 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs
@@ -14,17 +14,23 @@ public partial class FileTreeItem : ObservableObject
///
/// Gets or sets the name of the file or directory.
///
- public string Name { get; set; } = string.Empty;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsExecutable))]
+ private string _name = string.Empty;
///
/// Gets or sets a value indicating whether this item is a file.
///
- public bool IsFile { get; set; }
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsExecutable))]
+ private bool _isFile;
///
/// Gets or sets the full path of the file or directory.
///
- public string FullPath { get; set; } = string.Empty;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsExecutable))]
+ private string _fullPath = string.Empty;
///
/// Gets or sets the children of this item (for directories).
diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
index ae01bb830..fd7e36b13 100644
--- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
+++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs
@@ -699,7 +699,7 @@ private async Task AddLocalContentAsync(Avalonia.Controls.Window? owner)
if (dialogOwner == null) return;
- var vm = new AddLocalContentViewModel(
+ using var vm = new AddLocalContentViewModel(
_localContentService,
_contentStorageService,
_genLauncherNormalizationService,
@@ -767,7 +767,7 @@ private async Task EditContentAsync(ContentDisplayItem? contentItem)
if (owner == null) return;
- var vm = new AddLocalContentViewModel(
+ using var vm = new AddLocalContentViewModel(
_localContentService,
_contentStorageService,
_genLauncherNormalizationService,
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml
index 081789f27..d9256dfae 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml
+++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml
@@ -13,6 +13,7 @@
+
@@ -115,7 +116,13 @@
+ Classes="glass">
+
+
+
+
+
+
@@ -157,7 +164,7 @@
-
+
-
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -79,14 +168,18 @@
+ Background="Transparent" Foreground="#7C4DFF" BorderThickness="0" Padding="0" Margin="0,8,0,0" HorizontalAlignment="Left" Cursor="Hand"/>
-
+
+
+
+
@@ -117,84 +210,147 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml
index 245136041..d60cf5e9d 100644
--- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml
+++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml
@@ -8,7 +8,7 @@
Title="GenHub Updates"
Icon="/Assets/Icons/generalshub-icon.png"
WindowStartupLocation="CenterScreen"
- SystemDecorations="BorderOnly"
+ SystemDecorations="Full"
TransparencyLevelHint="AcrylicBlur"
Background="Transparent"
ExtendClientAreaToDecorationsHint="True"
@@ -61,34 +61,63 @@
-
+
+
+
+
+
+
+
diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs
index 5fd0a2bf2..ba60939db 100644
--- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs
+++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs
@@ -62,23 +62,43 @@ public async Task InitializeAsync()
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+ ///
+ /// Handles the maximize/restore button click event.
+ ///
+ /// The sender.
+ /// The event args.
+ private void MaximizeButton_Click(object? sender, RoutedEventArgs e)
+ {
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ }
+
///
/// Handles the close button click event.
///
/// The sender.
/// The event args.
- private void CloseButton_Click(object sender, RoutedEventArgs e)
+ private void CloseButton_Click(object? sender, RoutedEventArgs e)
{
Close();
}
///
- /// Handles pointer pressed event for the title bar to enable window dragging.
+ /// Handles pointer pressed event for the title bar to enable window dragging and double-click maximize.
///
/// The sender.
/// The pointer event args.
- private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e)
+ private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
- BeginMoveDrag(e);
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ if (e.ClickCount == 2)
+ {
+ MaximizeButton_Click(sender, new RoutedEventArgs());
+ }
+ else
+ {
+ BeginMoveDrag(e);
+ }
+ }
}
}
diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
index b62d5aeef..4fddc56f1 100644
--- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs
@@ -97,7 +97,7 @@ public async Task> StartProcessAsync(GameLaunch
if (process.HasExited)
{
- return await HandleImmediateProcessExitAsync(process, configuration, capturedErrors);
+ return HandleImmediateProcessExit(process, configuration, capturedErrors);
}
}
@@ -767,7 +767,7 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi
return processStartInfo;
}
- private async Task> HandleImmediateProcessExitAsync(
+ private OperationResult HandleImmediateProcessExit(
Process process,
GameLaunchConfiguration configuration,
BoundedErrorBuffer capturedErrors)
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml
index 8823922d1..7e77f4339 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml
+++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml
@@ -44,7 +44,9 @@
-
+
0 ? result[0].Path.LocalPath : null;
+
+ return folders.Count > 0 ? folders[0].Path.LocalPath : null;
};
vm.BrowseFileAction = async () =>
@@ -69,17 +70,48 @@ protected override void OnDataContextChanged(EventArgs e)
return null;
}
- var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
- Title = "Select Files",
+ Title = "Select Archive File",
AllowMultiple = true,
- FileTypeFilter = [FilePickerFileTypes.All, new("Zip Archives") { Patterns = ["*.zip"] }],
+ FileTypeFilter =
+ [
+ new FilePickerFileType("Archive Files")
+ {
+ Patterns = ["*.zip", "*.7z", "*.rar", "*.tar", "*.gz", "*.big"],
+ },
+ new FilePickerFileType("All Files")
+ {
+ Patterns = ["*.*"],
+ },
+ ],
});
- return result.Count > 0 ? result.Select(f => f.Path.LocalPath).ToList() : null;
+
+ return files.Count > 0 ? files.Select(f => f.Path.LocalPath).ToList() : null;
};
}
}
+ ///
+ /// Handles pointer pressed on the title bar for dragging and maximizing.
+ ///
+ /// The sender.
+ /// The event arguments.
+ private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ if (e.ClickCount == 2 && CanResize)
+ {
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ }
+ else
+ {
+ BeginMoveDrag(e);
+ }
+ }
+ }
+
private void OnAdminDrop(string[] files)
{
_ = ProcessAdminDropAsync(files);
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml
index e87816c32..ba31ea2a3 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml
+++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml
@@ -18,6 +18,7 @@
WindowStartupLocation="CenterOwner"
Background="#0F0F0F"
Icon="avares://GenHub/Assets/Icons/generalshub-icon.png"
+ SystemDecorations="Full"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1">
@@ -437,9 +438,7 @@
+ PointerPressed="OnHeaderPointerPressed">
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs
index 64273c1c5..bd3288392 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml.cs
@@ -3,7 +3,6 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
-using Avalonia.VisualTree;
using GenHub.Core.Constants;
using GenHub.Features.GameProfiles.ViewModels;
@@ -18,11 +17,6 @@ public partial class GameProfileSettingsWindow : Window
private static double? _savedWidth;
private static double? _savedHeight;
- // Fields for manual drag detection to allow double-click to work
- private bool _isMouseDown;
- private Point _mouseDownPosition;
- private PointerPressedEventArgs? _pressedEventArgs;
-
///
/// Initializes a new instance of the class.
///
@@ -30,9 +24,6 @@ public GameProfileSettingsWindow()
{
InitializeComponent();
- // Wire up drag handlers to the header in the shared content view
- WireUpDragHandlers();
-
// Subscribe to DataContext changes to handle commands
DataContextChanged += OnDataContextChanged;
@@ -50,69 +41,19 @@ public GameProfileSettingsWindow()
/// The event arguments.
public void OnHeaderPointerPressed(object? sender, PointerPressedEventArgs e)
{
- if (e.ClickCount == 2)
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
- WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
- _isMouseDown = false;
- _pressedEventArgs = null;
- }
- else
- {
- _isMouseDown = true;
- _mouseDownPosition = e.GetPosition(this);
- _pressedEventArgs = e;
- }
- }
-
- ///
- /// Handles pointer moved to initiate drag only after a threshold, allowing double-clicks to pass through.
- ///
- /// The sender.
- /// The event arguments.
- public void OnHeaderPointerMoved(object? sender, PointerEventArgs e)
- {
- if (!_isMouseDown || _pressedEventArgs == null)
- {
- return;
- }
-
- var currentPosition = e.GetPosition(this);
- var distance = Math.Sqrt(Math.Pow(currentPosition.X - _mouseDownPosition.X, 2) + Math.Pow(currentPosition.Y - _mouseDownPosition.Y, 2));
-
- // Drag threshold of 3 pixels
- if (distance > 3)
- {
- if (WindowState == WindowState.Maximized)
+ if (e.ClickCount == 2 && CanResize)
{
- var screenX = Position.X + (currentPosition.X * RenderScaling);
- var screenY = Position.Y + (currentPosition.Y * RenderScaling);
-
- WindowState = WindowState.Normal;
-
- var targetWidth = _savedWidth ?? Width;
- var newX = screenX - ((targetWidth * RenderScaling) / 2);
- var newY = screenY - (_mouseDownPosition.Y * RenderScaling);
-
- Position = new PixelPoint((int)newX, (int)newY);
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ }
+ else
+ {
+ BeginMoveDrag(e);
}
-
- BeginMoveDrag(_pressedEventArgs);
- _isMouseDown = false;
- _pressedEventArgs = null;
}
}
- ///
- /// Handles pointer released to reset drag state.
- ///
- /// The sender.
- /// The event arguments.
- public void OnHeaderPointerReleased(object? sender, PointerReleasedEventArgs e)
- {
- _isMouseDown = false;
- _pressedEventArgs = null;
- }
-
///
/// Handles the toggle fullscreen button click.
///
@@ -140,20 +81,6 @@ protected override void OnClosed(EventArgs e)
base.OnClosed(e);
}
- ///
- /// Wires up pointer event handlers to the header border in the shared content view.
- ///
- private void WireUpDragHandlers()
- {
- // Find the named header border in the shared content view
- if (this.FindControl("ContentView")?.FindControl("HeaderBorder") is { } headerBorder)
- {
- headerBorder.PointerPressed += OnHeaderPointerPressed;
- headerBorder.PointerMoved += OnHeaderPointerMoved;
- headerBorder.PointerReleased += OnHeaderPointerReleased;
- }
- }
-
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs
index 07fe4ce37..78c4b9180 100644
--- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs
+++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml.cs
@@ -1,8 +1,11 @@
+using System;
using Avalonia;
using Avalonia.Controls;
+#if DEBUG
+using Avalonia.Diagnostics;
+#endif
using Avalonia.Markup.Xaml;
using GenHub.Features.GameProfiles.ViewModels.Wizard;
-using System;
namespace GenHub.Features.GameProfiles.Views.Wizard;
diff --git a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs
index 1adbe2190..50ced9287 100644
--- a/GenHub/GenHub/Features/Info/Services/MockToolServices.cs
+++ b/GenHub/GenHub/Features/Info/Services/MockToolServices.cs
@@ -6,6 +6,7 @@
using System.Reactive.Subjects;
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.GameProfiles;
@@ -43,6 +44,7 @@ public class MockNotificationService : INotificationService
private readonly Subject _dismissRequests = new();
private readonly Subject _dismissAllRequests = new();
private readonly Subject _notificationHistory = new();
+ private readonly Subject<(Guid Id, string? Title, string Message)> _updateRequests = new();
///
public IObservable Notifications => _notifications.AsObservable();
@@ -56,6 +58,9 @@ public class MockNotificationService : INotificationService
///
public IObservable NotificationHistory => _notificationHistory.AsObservable();
+ ///
+ public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateRequests.AsObservable();
+
///
public void Show(NotificationMessage notification) => _notifications.OnNext(notification);
@@ -75,6 +80,10 @@ public void ShowWarning(string title, string message, int? autoDismissMs = null,
public void ShowError(string title, string message, int? autoDismissMs = null, bool showInBadge = false)
=> Show(new NotificationMessage(NotificationType.Error, title, message, autoDismissMs, showInBadge: showInBadge));
+ ///
+ public void Update(Guid notificationId, string message, string? title = null)
+ => _updateRequests.OnNext((notificationId, title, message));
+
///
public void Dismiss(Guid id) => _dismissRequests.OnNext(id);
@@ -592,6 +601,12 @@ public static void UseDefaultConfiguration()
///
public bool GetAutoCheckForUpdatesOnStartup() => true;
+ ///
+ public bool GetAutoCheckForUpdatesPeriodically() => true;
+
+ ///
+ public int GetPeriodicUpdateCheckIntervalMinutes() => AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+
///
public bool GetEnableDetailedLogging() => false;
diff --git a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs
index 92c8154e2..fac3c8e95 100644
--- a/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs
+++ b/GenHub/GenHub/Features/Notifications/Services/NotificationService.cs
@@ -24,6 +24,7 @@ public class NotificationService : INotificationService, IDisposable
private readonly Subject _dismissSubject = new();
private readonly Subject _dismissAllSubject = new();
private readonly Subject _historySubject = new();
+ private readonly Subject<(Guid Id, string? Title, string Message)> _updateSubject = new();
private readonly List _notificationHistory = new();
private readonly object _historyLock = new();
private readonly object _muteLock = new();
@@ -74,6 +75,9 @@ public NotificationService(
///
public IObservable NotificationHistory => _historySubject;
+ ///
+ public IObservable<(Guid Id, string? Title, string Message)> UpdateRequests => _updateSubject;
+
///
public NotificationMuteState MuteState
{
@@ -175,6 +179,35 @@ public void Show(NotificationMessage notification)
}
}
+ ///
+ public void Update(Guid notificationId, string message, string? title = null)
+ {
+ if (_disposed)
+ {
+ _logger.LogWarning("Attempted to update notification after service disposal");
+ return;
+ }
+
+ ArgumentNullException.ThrowIfNull(message);
+
+ lock (_historyLock)
+ {
+ var index = _notificationHistory.FindIndex(n => n.Id == notificationId);
+ if (index >= 0)
+ {
+ var existing = _notificationHistory[index];
+ _notificationHistory[index] = existing with
+ {
+ Title = title ?? existing.Title,
+ Message = message,
+ };
+ }
+ }
+
+ _logger.LogDebug("Updating notification {NotificationId}: {Message}", notificationId, message);
+ _updateSubject.OnNext((notificationId, title, message));
+ }
+
///
public async Task MuteSession(CancellationToken cancellationToken = default)
{
@@ -317,6 +350,7 @@ public void Dispose()
_dismissSubject?.Dispose();
_dismissAllSubject?.Dispose();
_historySubject?.Dispose();
+ _updateSubject?.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs
index 235bacfea..a010e81bd 100644
--- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs
+++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationItemViewModel.cs
@@ -37,15 +37,11 @@ public partial class NotificationItemViewModel : ViewModelBase, IDisposable
///
public NotificationType Type { get; }
- ///
- /// Gets the notification title.
- ///
- public string Title { get; }
+ [ObservableProperty]
+ private string _title;
- ///
- /// Gets the notification message.
- ///
- public string Message { get; }
+ [ObservableProperty]
+ private string _message;
///
/// Gets the timestamp when the notification was created.
@@ -118,8 +114,8 @@ public NotificationItemViewModel(
Id = notification.Id;
Type = notification.Type;
- Title = notification.Title;
- Message = notification.Message;
+ _title = notification.Title;
+ _message = notification.Message;
Timestamp = notification.Timestamp;
IsActionable = notification.IsActionable;
_isVisible = false;
@@ -135,10 +131,7 @@ public NotificationItemViewModel(
StartDismissTimer(notification.AutoDismissMilliseconds.Value);
}
- Dispatcher.UIThread.Post(() =>
- {
- IsVisible = true;
- });
+ Dispatcher.UIThread.Post(() => IsVisible = true);
}
///
diff --git a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs
index 2637c7758..8b174a82f 100644
--- a/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs
+++ b/GenHub/GenHub/Features/Notifications/ViewModels/NotificationManagerViewModel.cs
@@ -20,6 +20,7 @@ public class NotificationManagerViewModel : ViewModelBase, IDisposable
private readonly IDisposable _notificationSubscription;
private readonly IDisposable _dismissSubscription;
private readonly IDisposable _dismissAllSubscription;
+ private readonly IDisposable _updateSubscription;
private readonly object _lock = new();
private bool _disposed;
@@ -48,6 +49,7 @@ public NotificationManagerViewModel(
_notificationSubscription = _notificationService.Notifications.Subscribe(HandleNotificationReceived);
_dismissSubscription = _notificationService.DismissRequests.Subscribe(HandleDismissRequest);
_dismissAllSubscription = _notificationService.DismissAllRequests.Subscribe(_ => HandleDismissAllRequest());
+ _updateSubscription = _notificationService.UpdateRequests.Subscribe(HandleUpdateRequest);
_logger.LogInformation("NotificationManagerViewModel initialized");
}
@@ -133,6 +135,7 @@ public void Dispose()
_notificationSubscription?.Dispose();
_dismissSubscription?.Dispose();
_dismissAllSubscription?.Dispose();
+ _updateSubscription?.Dispose();
foreach (var notification in ActiveNotifications)
{
@@ -157,6 +160,37 @@ private void HandleDismissRequest(Guid notificationId)
RemoveNotification(notificationId);
}
+ private void HandleUpdateRequest((Guid Id, string? Title, string Message) update)
+ {
+ _logger.LogDebug("Update request received for notification {NotificationId}", update.Id);
+ Dispatcher.UIThread.InvokeAsync(
+ () =>
+ {
+ try
+ {
+ lock (_lock)
+ {
+ var notification = ActiveNotifications.FirstOrDefault(n => n.Id == update.Id);
+ if (notification != null)
+ {
+ if (update.Title is not null)
+ {
+ notification.Title = update.Title;
+ }
+
+ notification.Message = update.Message;
+ _logger.LogDebug("Updated notification {NotificationId} message: {Message}", update.Id, update.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating notification {NotificationId}", update.Id);
+ }
+ },
+ DispatcherPriority.Send);
+ }
+
private void HandleDismissAllRequest()
{
_logger.LogDebug("Dismiss all request received");
diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
index 32cce78ad..07322e27e 100644
--- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
+++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs
@@ -128,6 +128,12 @@ public partial class SettingsViewModel : ObservableObject, IDisposable
[ObservableProperty]
private bool _autoCheckForUpdatesOnStartup = true;
+ [ObservableProperty]
+ private bool _autoCheckForUpdatesPeriodically = true;
+
+ [ObservableProperty]
+ private int _periodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+
[ObservableProperty]
private bool _allowBackgroundDownloads = true;
@@ -195,12 +201,6 @@ public partial class SettingsViewModel : ObservableObject, IDisposable
[ObservableProperty]
private string _patStatusMessage = string.Empty;
- [ObservableProperty]
- private bool _isLoadingArtifacts;
-
- [ObservableProperty]
- private ObservableCollection _availableArtifacts = [];
-
///
/// Initializes a new instance of the class.
///
@@ -486,6 +486,8 @@ private void LoadSettings()
WorkspacePath = settings.WorkspacePath;
MaxConcurrentDownloads = settings.MaxConcurrentDownloads;
AutoCheckForUpdatesOnStartup = settings.AutoCheckForUpdatesOnStartup;
+ AutoCheckForUpdatesPeriodically = settings.AutoCheckForUpdatesPeriodically;
+ PeriodicUpdateCheckIntervalMinutes = settings.PeriodicUpdateCheckIntervalMinutes;
AllowBackgroundDownloads = settings.AllowBackgroundDownloads;
EnableDetailedLogging = settings.EnableDetailedLogging;
DefaultWorkspaceStrategy = settings.DefaultWorkspaceStrategy;
@@ -540,6 +542,8 @@ private async Task SaveSettings()
settings.WorkspacePath = WorkspacePath;
settings.MaxConcurrentDownloads = MaxConcurrentDownloads;
settings.AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup;
+ settings.AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically;
+ settings.PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes;
settings.AllowBackgroundDownloads = AllowBackgroundDownloads;
settings.EnableDetailedLogging = EnableDetailedLogging;
settings.DefaultWorkspaceStrategy = DefaultWorkspaceStrategy;
@@ -569,6 +573,12 @@ private async Task SaveSettings()
await _userSettingsService.SaveAsync();
+ // Notify components of updated update settings
+ WeakReferenceMessenger.Default.Send(new UpdateSettingsChangedMessage(
+ AutoCheckForUpdatesOnStartup,
+ AutoCheckForUpdatesPeriodically,
+ PeriodicUpdateCheckIntervalMinutes));
+
// Apply log level change immediately without restart
Infrastructure.DependencyInjection.LoggingModule.SetLogLevel(EnableDetailedLogging);
@@ -600,6 +610,8 @@ private async Task ResetToDefaults()
WorkspacePath = string.Empty;
MaxConcurrentDownloads = DownloadDefaults.MaxConcurrentDownloads;
AutoCheckForUpdatesOnStartup = true;
+ AutoCheckForUpdatesPeriodically = true;
+ PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
AllowBackgroundDownloads = true;
EnableDetailedLogging = false;
DefaultWorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy;
@@ -744,6 +756,14 @@ private bool ValidateSettings()
DownloadBufferSizeKB = DownloadDefaults.BufferSizeKB;
}
+ // Validate periodic update check interval
+ if (PeriodicUpdateCheckIntervalMinutes < AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes ||
+ PeriodicUpdateCheckIntervalMinutes > AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)
+ {
+ _logger.LogWarning("Invalid PeriodicUpdateCheckIntervalMinutes value: {Value}. Resetting to default.", PeriodicUpdateCheckIntervalMinutes);
+ PeriodicUpdateCheckIntervalMinutes = AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+ }
+
// Validate game install path if specified
if (!string.IsNullOrEmpty(WorkspacePath) && !Directory.Exists(WorkspacePath))
{
@@ -1007,7 +1027,6 @@ private async Task DeletePatAsync()
HasGitHubPat = false;
IsPatValid = false;
PatStatusMessage = "GitHub PAT removed";
- AvailableArtifacts.Clear();
}
catch (Exception ex)
{
@@ -1034,45 +1053,6 @@ private void OpenUpdateWindow()
}
}
- ///
- /// Loads available CI artifacts for selection.
- ///
- [RelayCommand]
- private async Task LoadArtifactsAsync()
- {
- if (_updateManager == null || !HasGitHubPat)
- {
- PatStatusMessage = "Configure a GitHub PAT to load artifacts";
- return;
- }
-
- IsLoadingArtifacts = true;
- AvailableArtifacts.Clear();
-
- try
- {
- var artifact = await _updateManager.CheckForArtifactUpdatesAsync();
- if (artifact != null)
- {
- AvailableArtifacts.Add(artifact);
- PatStatusMessage = $"Found {AvailableArtifacts.Count} artifact(s)";
- }
- else
- {
- PatStatusMessage = "No artifacts available";
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to load artifacts");
- PatStatusMessage = $"Error loading artifacts: {ex.Message}";
- }
- finally
- {
- IsLoadingArtifacts = false;
- }
- }
-
[RelayCommand]
private async Task DeleteAllData()
{
diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
index 719a8ae05..7a022331b 100644
--- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
+++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml
@@ -734,6 +734,28 @@
Classes="setting-description" Margin="24,0,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
@@ -770,10 +792,6 @@
-
@@ -800,38 +818,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs
index 35feceb9d..a4bb91502 100644
--- a/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs
+++ b/GenHub/GenHub/Features/Settings/Views/SettingsView.axaml.cs
@@ -20,7 +20,7 @@ public SettingsView()
InitializeComponent();
// Handle pointer press to unfocus text boxes when clicking elsewhere
- this.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
+ AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
}
///
@@ -59,7 +59,7 @@ protected override void OnDataContextChanged(EventArgs e)
if (DataContext is SettingsViewModel vm)
{
// Sync visibility state with current visual tree state
- vm.IsViewVisible = this.VisualRoot != null;
+ vm.IsViewVisible = VisualRoot != null;
}
}
@@ -68,7 +68,7 @@ private void OnPointerPressed(object? sender, Avalonia.Input.PointerPressedEvent
// If clicking outside of a TextBox, clear focus from any focused TextBox
if (e.Source is not TextBox)
{
- this.Focus();
+ Focus();
}
}
@@ -95,24 +95,6 @@ private void OnOpenPatCreationUrl(object? sender, RoutedEventArgs e)
}
}
- private void OnViewWorkflowRun(object? sender, RoutedEventArgs e)
- {
- if (sender is Button button && button.Tag is string url && !string.IsNullOrEmpty(url))
- {
- try
- {
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
- {
- UseShellExecute = true,
- });
- }
- catch
- {
- // Silently fail if browser cannot be opened
- }
- }
- }
-
///
/// Loads and initializes the XAML components for this view.
///
diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs
index a1e4365df..a371e17bd 100644
--- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs
+++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs
@@ -42,7 +42,7 @@ public async Task ImportFromUrlAsync(
var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
- var fileName = GetFileNameFromUri(new Uri(url), response);
+ var fileName = ExtractFileName(new Uri(url), response);
Directory.CreateDirectory(tempDir);
var tempPath = Path.Combine(tempDir, fileName);
@@ -512,7 +512,7 @@ public async Task ImportFromStreamAsync(
}
}
- private static string GetFileNameFromUri(Uri uri, HttpResponseMessage response)
+ private static string ExtractFileName(Uri uri, HttpResponseMessage response)
{
var rawName = response.Content.Headers.ContentDisposition?.FileNameStar
?? response.Content.Headers.ContentDisposition?.FileName;
diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs
index 827c83b21..2b6540a4d 100644
--- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs
+++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs
@@ -101,7 +101,7 @@ public async Task ImportFromUrlAsync(
return await ImportFromZipAsync(tempPath, targetVersion, progress, ct);
}
- var importedFileName = GetFileNameFromUri(new Uri(directUrl));
+ var importedFileName = ExtractFileName(new Uri(directUrl));
using var stream = File.OpenRead(tempPath);
return await ImportFromStreamAsync(stream, importedFileName, targetVersion, ct);
}
@@ -336,7 +336,7 @@ private static string GetUniquePath(string path)
return path;
}
- private static string GetFileNameFromUri(Uri uri)
+ private static string ExtractFileName(Uri uri)
{
try
{
diff --git a/docs/dev/constants.md b/docs/dev/constants.md
index 0c05a990e..628285b5c 100644
--- a/docs/dev/constants.md
+++ b/docs/dev/constants.md
@@ -68,11 +68,60 @@ Application-wide constants for GenHub.
Constants related to application updates and Velopack.
-| Constant | Value/Type | Description |
-| ---------------------------- | --------------------------- | ------------------------------------------------ |
-| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update |
-| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks |
-| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests |
+| Constant | Value/Type | Description |
+| --------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------- |
+| `MaxHttpRetries` | `3` | Maximum number of HTTP retries for failed requests |
+| `UpdateTabIndex` | `0` | Index for the Update tab in update notification views |
+| `BrowseBuildsTabIndex` | `1` | Index for the Browse Builds tab in update notification views |
+| `MaxTabIndex` | `1` | Maximum valid tab index in update notification views |
+| `VelopackDirectory` | `"velopack"` | Velopack directory name |
+| `ArtifactPrefixWindows` | `"genhub-velopack-windows-"`| Artifact name prefix for Windows builds |
+| `ArtifactPrefixLinux` | `"genhub-velopack-linux-"` | Artifact name prefix for Linux builds |
+| `ArtifactNameRelease` | `"GenHub-Release"` | Artifact name for release builds |
+| `PlatformWindows` | `"windows"` | Platform string for Windows |
+| `PlatformLinux` | `"linux"` | Platform string for Linux |
+| `CheckingForUpdatesMessage` | `"Checking..."` | Update checking message |
+| `UpdateAvailableTitleFormat` | `"Update available: v{0}"` | Update available title format string |
+| `UpdateUpToDateMessage` | `"You're up to date!"` | Update up to date message |
+| `UpdateCheckFailedMessage` | `"Update check failed"` | Update check failed message |
+| `InstallingMessage` | `"Installing..."` | Installing message |
+| `InstallUpdateAction` | `"Install Update"` | Install update action text |
+| `InitializingMessage` | `"Initializing..."` | Initializing message |
+| `ReadyToRestartMessage` | `"Ready to restart"` | Ready to restart message |
+| `DownloadingFormat` | `"Downloading... {0}%"` | Downloading format string |
+| `UpdateDownloadedRestartingMessage` | `"Update downloaded! Restarting application..."` | Update downloaded and restarting message |
+| `UpdateCompleteRestartingMessage` | `"Update complete! Restarting..."` | Update complete and restarting message |
+| `DownloadingUpdateMessage` | `"Downloading update..."` | Downloading update status message |
+| `CannotInstallFromLocationMessage` | `"Cannot install from this location"` | Cannot install from location status message |
+| `UpdateFailedMessage` | `"Update failed"` | Update failed status message |
+| `InstallationFailedMessage` | `"Installation failed"` | Installation failed status message |
+| `NoArtifactAvailableMessage` | `"No artifact available"` | No artifact available status message |
+| `NoVersionsFoundMessage` | `"No versions found"` | No versions found dropdown placeholder |
+| `LoadingVersionsMessage` | `"Loading versions..."` | Loading versions dropdown placeholder |
+| `SelectVersionMessage` | `"Select a version"` | Select a version dropdown placeholder |
+| `NotAvailable` | `"N/A"` | Not available string |
+| `UpdateInstallationRequiresAppInstalledMessage` | Format string | Message format when trying to install update from uninstalled debug directory |
+| `UpdateAvailableNotificationTitle` | `"Update Available"` | Update available notification title for release channel |
+| `BranchUpdateAvailableNotificationTitle` | `"Branch Update Available"` | Update available notification title for branch subscriptions |
+| `PrUpdateAvailableNotificationTitle` | `"PR Update Available"` | Update available notification title for PR subscriptions |
+| `UpdateAction` | `"Update"` | Update action button text |
+| `UpdatingAppNotificationTitle` | `"Updating GenHub"` | Title for update in progress notification |
+| `UpdateStartingMessage` | `"Starting update..."` | Starting update progress message |
+| `UpdateFailedNotificationTitle` | `"Update Failed"` | Title for update failed notification |
+| `UpdateFailedNotificationFormat` | `"Failed to install update: {0}"` | Update failed notification body format string |
+| `ViewUpdatesAction` | `"View Updates"` | View updates action button text |
+| `ReleaseUpdateNotificationFormat` | `"A new version ({0}) is available."` | Release update notification body format string |
+| `BranchUpdateNotificationFormat` | `"A new build ({0}) is available on branch '{1}'."` | Branch update notification body format string |
+| `PrUpdateNotificationFormat` | `"A new build ({0}) is available for PR #{1}."` | PR update notification body format string |
+| `SortOptionLastUpdated` | `"Last Updated"` | Sort option: sort by last updated date descending |
+| `SortOptionPrNumberDesc` | `"PR Number (Highest)"` | Sort option: sort by pull request number descending |
+| `SortOptionPrNumberAsc` | `"PR Number (Lowest)"` | Sort option: sort by pull request number ascending |
+| `DefaultPeriodicUpdateCheckIntervalMinutes` | `30` | Default interval in minutes for periodic update checks (30 minutes) |
+| `MinPeriodicUpdateCheckIntervalMinutes` | `5` | Minimum interval in minutes for periodic update checks (5 minutes) |
+| `MaxPeriodicUpdateCheckIntervalMinutes` | `10080` | Maximum interval in minutes for periodic update checks (10080 minutes / 7 days) |
+| `PeriodicUpdateCheckIntervalIncrementMinutes` | `5` | Increment step in minutes for periodic update check interval setting (5 minutes) |
+| `PostUpdateExitDelay` | `TimeSpan.FromSeconds(5)` | Delay before exit after applying update (5 seconds) |
+| `CacheDuration` | `TimeSpan.FromHours(1)` | Cache duration for update checks (1 hour) |
---
diff --git a/docs/dev/index.md b/docs/dev/index.md
index a9350bf97..e8c8e1a56 100644
--- a/docs/dev/index.md
+++ b/docs/dev/index.md
@@ -64,6 +64,12 @@ client.Timeout = TimeIntervals.DownloadTimeout;
---
+### Window Styling & OS Animations
+
+GeneralsHub defines a mandatory [Window styling and OS animation standard](./window-styling.md) to ensure all windows achieve smooth, native Desktop Window Manager (DWM) animations, proper client area extension, and reliable title bar drag/maximize handling.
+
+---
+
## Architecture
### Dependency Injection
diff --git a/docs/dev/window-styling.md b/docs/dev/window-styling.md
new file mode 100644
index 000000000..dd0d0ab20
--- /dev/null
+++ b/docs/dev/window-styling.md
@@ -0,0 +1,147 @@
+---
+title: Window Styling and OS Animation Standards
+description: Guidelines and architectural rules for Avalonia window configuration, custom title bars, and native OS maximize/restore animations in GenHub
+---
+
+# Window Styling & OS Animation Standards
+
+This document establishes the mandatory standards for creating and configuring `Window` instances in GenHub. Following these patterns ensures that all windows achieve smooth, native OS animations (such as Desktop Window Manager / DWM fluid maximize, restore, snap, and dragging transitions) without clunkiness or visual glitches.
+
+---
+
+## 1. The Core Architecture: Native DWM Integration
+
+Avalonia runs cross-platform across Windows, Linux, and macOS. On Windows (Win32), the operating system's **Desktop Window Manager (DWM)** manages fluid maximize/restore zoom animations, Aero Snap, and window shadows.
+
+For DWM to provide native fluid animations on windows with custom-styled title bars, the window **MUST** retain its native top-level frame (`WS_OVERLAPPEDWINDOW`) while extending its client area over the OS chrome.
+
+### Mandatory Window XAML Properties
+
+All resizable windows with custom title bars in GenHub must define these attributes:
+
+```xml
+
+```
+
+### Why Each Property Matters
+
+| Property | Value | Purpose | Why It Fails Without It |
+|---|---|---|---|
+| `SystemDecorations` | `"Full"` | Retains top-level OS window styles (`WS_CAPTION`, `WS_THICKFRAME`, `WS_MAXIMIZEBOX`). | Setting `"BorderOnly"` or `"None"` strips maximize styles, causing DWM to disable maximize/restore animations and snap instantly. |
+| `ExtendClientAreaToDecorationsHint` | `"True"` | Extends the application XAML drawing surface across the entire window. | Without it, the OS renders a standard generic white/grey caption bar above the content. |
+| `ExtendClientAreaChromeHints` | `"NoChrome"` | Hides the default OS minimize, maximize, and close caption buttons. | Without it, default OS caption buttons clash with custom UI buttons. |
+| `ExtendClientAreaTitleBarHeightHint` | `"-1"` | Instructs Avalonia to remove default title bar reservation space. | Ensures full control of header height via XAML. |
+
+---
+
+## 2. Standard Title Bar Interaction Pattern
+
+### XAML Header Definition
+
+The header area should be an interactive container (`Grid` or `Border`) with a transparent background that captures pointer events:
+
+```xml
+
+
+
+
+```
+
+> [!IMPORTANT]
+> Never set `IsHitTestVisible="False"` on the drag area container, or pointer events cannot be captured for dragging or double-click maximizing.
+
+### Code-Behind Handler
+
+The code-behind must implement pointer dragging and double-click maximizing using Avalonia's built-in `BeginMoveDrag`:
+
+```csharp
+///
+/// Handles pointer pressed events on the title bar for dragging and maximizing.
+///
+/// The sender object.
+/// The pointer event arguments.
+private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
+{
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ if (e.ClickCount == 2 && CanResize)
+ {
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+ }
+ else
+ {
+ BeginMoveDrag(e);
+ }
+ }
+}
+
+///
+/// Handles the maximize/restore button click.
+///
+/// The sender object.
+/// The routed event arguments.
+private void MaximizeButton_Click(object? sender, RoutedEventArgs e)
+{
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+}
+```
+
+---
+
+## 3. Strict Rules & Anti-Patterns (For Agents & Developers)
+
+> [!CAUTION]
+> **NEVER MANUALLY TRACK MOUSE MOVES OR MANUALLY UNMAXIMIZE DURING DRAG**
+>
+> A common anti-pattern is writing manual `PointerMoved` tracking with a pixel distance threshold, manually setting `WindowState = WindowState.Normal`, calculating pixel coordinates, and setting `Position = new PixelPoint(...)`.
+>
+> **Why this breaks:**
+> 1. It bypasses DWM's native interactive unmaximize animation.
+> 2. It causes the window to jarringly jump/teleport on screen.
+> 3. It breaks mouse capture and makes window dragging feel laggy and disconnected.
+>
+> **Solution:** Always call `BeginMoveDrag(e)` directly on pointer press. Avalonia and the OS window manager will handle dragging off maximized state smoothly.
+
+---
+
+> [!CAUTION]
+> **NEVER USE `SystemDecorations="BorderOnly"` ON RESIZABLE/MAXIMIZABLE WINDOWS**
+>
+> Setting `BorderOnly` disables DWM maximize/restore zoom transitions. Always use `SystemDecorations="Full"` combined with `ExtendClientArea*`.
+
+---
+
+## 4. Window Types Reference in GenHub
+
+| Window Class | Role | `SystemDecorations` | `CanResize` | Custom Title Bar Drag |
+|---|---|---|---|---|
+| `MainWindow` | Primary application shell | `Full` | `True` | `OnTitleBarPointerPressed` |
+| `GameProfileSettingsWindow` | Profile configuration editor | `Full` | `True` | `OnHeaderPointerPressed` |
+| `UpdateNotificationWindow` | Velopack update dialog | `Full` | `True` | `TitleBar_PointerPressed` |
+| `AddLocalContentWindow` | Content importer dialog | `Full` | `True` | `OnTitleBarPointerPressed` |
+| `GenericMessageWindow` | Modal message/announcement dialog | `None` | `False` | Drag anywhere (`OnPointerPressed`) |
+| `ConfirmationDialogWindow` | Modal confirmation dialog | `None` | `False` | Drag anywhere (`OnPointerPressed`) |
+| `UpdateOptionDialogWindow` | Modal update option dialog | `None` | `False` | Modal centered |
+| `SetupWizardView` | First-run wizard dialog | `None` | `False` | Modal centered |
+| `GitHubTokenDialogView` | GitHub PAT configuration dialog | `BorderOnly` | `False` | Modal centered |
+
+---
+
+## 5. Checklist for New Windows
+
+When creating a new `Window` in GenHub:
+
+- [ ] Set `SystemDecorations="Full"` if the window can be resized or maximized.
+- [ ] Set `ExtendClientAreaToDecorationsHint="True"`, `ExtendClientAreaChromeHints="NoChrome"`, and `ExtendClientAreaTitleBarHeightHint="-1"`.
+- [ ] Implement `OnTitleBarPointerPressed` with `BeginMoveDrag(e)` and double-click maximize toggle.
+- [ ] Ensure the drag container has `Background="Transparent"` and `IsHitTestVisible="True"`.
+- [ ] Avoid manual coordinate calculation or custom drag threshold tracking.
+- [ ] Adhere to code style: no `this.`, primary constructors where applicable, no mid-comment capitalization.
diff --git a/docs/velopack-integration.md b/docs/velopack-integration.md
index e347e1ffa..4ba8ecb11 100644
--- a/docs/velopack-integration.md
+++ b/docs/velopack-integration.md
@@ -110,38 +110,52 @@ This allows users to reinstall the same PR build with different commits without
## Update Channels
-GenHub provides two update channels that users can switch between:
+GenHub provides three update channels that users can switch between:
-### Stable Channel
+### 1. Stable Channel (Default)
- **Source**: GitHub Releases
-- **Versions**: `0.0.X` (no PR suffix)
-- **Updates**: Only stable builds from main branch
-- **Recommended for**: Production use
+- **Versions**: `0.0.X` (no branch/PR suffix)
+- **Updates**: Only published releases from the main branch
+- **Recommended for**: General production use
-### Artifacts Channel (PR Subscription)
+### 2. PR Artifacts Channel (PR Subscription)
-- **Source**: GitHub Actions CI artifacts
+- **Source**: GitHub Actions CI workflow artifacts
- **Versions**: `0.0.X-prY` format
-- **Updates**: Specific PR builds
-- **Recommended for**: Testing features, bug fixes
+- **Updates**: Specific Pull Request CI builds
+- **Recommended for**: Testing specific feature branches or bug fix pull requests
- **Requires**: GitHub Personal Access Token (PAT) with `repo` scope
#### Subscribing to PR Builds
1. Navigate to Settings → Updates
2. Click "Manage Updates & PRs"
-3. Enter GitHub PAT (if not already configured)
-4. Select a PR from the list
-5. Click "Subscribe"
+3. In the "Browse Builds" tab, select a pull request
+4. Click "Subscribe"
-The app will now check for updates from that PR instead of stable releases.
+The application will automatically query and notify when newer CI builds are published for that PR.
+
+### 3. Branch Artifacts Channel (Branch Subscription)
+
+- **Source**: GitHub Actions CI workflow artifacts on a branch (e.g., `development`, `main`)
+- **Versions**: `0.0.X-branchname` format
+- **Updates**: Continuous integration builds on the selected branch
+- **Recommended for**: Developers and testers wanting bleeding-edge builds
#### Unsubscribing
1. Open "Manage Updates & PRs"
-2. Click "Unsubscribe" on the currently subscribed PR
-3. App returns to stable channel
+2. Click "Unsubscribe" on the currently subscribed PR or branch
+3. The app returns to the stable release channel
+
+### Periodic Background Update Checks
+
+GenHub supports periodic background update checks configured in **Settings**:
+- **Automatic Background Checks**: Enable or disable periodic checks
+- **Configurable Interval**: Set between 5 minutes and 7 days (default: 30 minutes)
+- **Persistent Notifications & Badges**: Prompts users with a non-intrusive one-click "Update" action in the notification feed
+- **Duplicate Prevention**: Notification records are uniquely tracked per update identity (`pr:{prNumber}:{version}`, `branch:{branch}:{version}`, or `release:{version}`) to avoid notification spam
## Building Releases
From 0161c393786bb7c3d4045ff8d039dce5979ae455 Mon Sep 17 00:00:00 2001
From: Undead <110314402+undead2146@users.noreply.github.com>
Date: Wed, 19 Aug 2026 10:54:09 +0200
Subject: [PATCH 3/6] feat(content): add GeneralsGamePatch2 download option
under TheSuperHackers provider (#392)
---
.../Constants/SuperHackersConstants.cs | 15 +
.../Manifest/ManifestIdJsonConverter.cs | 2 +-
.../JsonWorkspaceStrategyConverter.cs | 2 +-
.../ConfigurationProviderServiceTests.cs | 3 +-
.../Content/ContentOrchestratorTests.cs | 58 ++
.../PublisherManifestFactoryResolverTests.cs | 173 ++++++
.../Publishers/SuperHackersProviderTests.cs | 497 ++++++++++++++++++
.../Services/ConfigurationProviderService.cs | 6 +-
.../Content/Services/ContentOrchestrator.cs | 15 +-
.../Content/Services/GitHub/GitHubResolver.cs | 10 +
.../PublisherManifestFactoryResolver.cs | 22 +-
.../Publishers/SuperHackersProvider.cs | 123 +++--
12 files changed, 876 insertions(+), 50 deletions(-)
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
index b15606e59..d5d3dffce 100644
--- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
@@ -60,6 +60,21 @@ public static class SuperHackersConstants
///
public const string GeneralsGameCodeRepo = "GeneralsGameCode";
+ ///
+ /// GitHub owner for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Owner = "TheSuperHackers";
+
+ ///
+ /// GitHub repo for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2";
+
+ ///
+ /// Display name for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2DisplayName = "Community Patch 2";
+
// ===== Service Configuration =====
///
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
index 83d681312..7a93f17cf 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
@@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest;
public sealed class ManifestIdJsonConverter : JsonConverter
{
///
- public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
var s = reader.GetString() ?? string.Empty;
return ManifestId.Create(s);
diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
index d0f2f01d3..04375f0ea 100644
--- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
+++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
@@ -16,7 +16,7 @@ public class JsonWorkspaceStrategyConverter : JsonConverter
[SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
[SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
[SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
- public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
if (reader.TokenType == JsonTokenType.Number)
{
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
index 10ccb53a2..7cd571b77 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
@@ -836,7 +836,8 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults()
// Assert
Assert.Contains("TheSuperHackers/GeneralsGameCode", result);
- Assert.Single(result);
+ Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result);
+ Assert.Equal(2, result.Count);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
index 5422334ce..33d4f7d2a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
@@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate
await Assert.ThrowsAnyAsync(
() => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token));
}
+
+ ///
+ /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync()
+ {
+ // Arrange
+ var specializedProviderMock = new Mock();
+ var githubProviderMock = new Mock();
+
+ const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2";
+
+ var specializedResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "TheSuperHackers Patch 2",
+ ProviderName = "thesuperhackers",
+ };
+
+ var githubResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "GeneralsGamePatch2",
+ ProviderName = "GitHub",
+ };
+
+ specializedProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult]));
+
+ githubProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([githubResult]));
+
+ var orchestrator = new ContentOrchestrator(
+ _loggerMock.Object,
+ [githubProviderMock.Object, specializedProviderMock.Object],
+ [],
+ [],
+ _cacheMock.Object,
+ _contentValidatorMock.Object,
+ _manifestPoolMock.Object,
+ _installationServiceMock.Object,
+ _installationCasPoolServiceMock.Object);
+
+ // Act
+ var result = await orchestrator.SearchAsync(new ContentSearchQuery());
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("thesuperhackers", items[0].ProviderName);
+ Assert.Equal("TheSuperHackers Patch 2", items[0].Name);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs
new file mode 100644
index 000000000..522569cd5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services.Publishers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.Publishers;
+
+///
+/// Unit tests for .
+///
+public class PublisherManifestFactoryResolverTests
+{
+ private readonly Mock _hashProviderMock;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PublisherManifestFactoryResolverTests()
+ {
+ _hashProviderMock = new Mock();
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns the specialized factory when CanHandle matches.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsSpecializedFactory_WhenCanHandleMatches()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory, gitHubFactory],
+ NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.thesuperhackers.gameclient.generals"),
+ ContentType = ContentType.GameClient,
+ Publisher = new PublisherInfo
+ {
+ Name = "TheSuperHackers",
+ PublisherType = PublisherTypeConstants.TheSuperHackers,
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(manifest);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory falls back to GitHubManifestFactory for non-GameClient publisher content.
+ ///
+ [Fact]
+ public void ResolveFactory_FallsBackToGitHubFactory_WhenSpecializedFactoryCannotHandle()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory, gitHubFactory],
+ NullLogger.Instance);
+
+ var patchManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.thesuperhackers.patch.generalsgamepatch2"),
+ ContentType = ContentType.Patch,
+ Publisher = new PublisherInfo
+ {
+ Name = "TheSuperHackers",
+ PublisherType = PublisherTypeConstants.TheSuperHackers,
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(patchManifest);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns null when no specialized or fallback factory is available.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsNull_WhenNoFactoryMatchesAndNoFallbackAvailable()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory],
+ NullLogger.Instance);
+
+ var patchManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.testpublisher.mod.sample"),
+ ContentType = ContentType.Mod,
+ Publisher = new PublisherInfo
+ {
+ Name = "Unknown",
+ PublisherType = "unknown",
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(patchManifest);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns null when a GameClient manifest has no specialized factory,
+ /// rather than falling back to GitHubManifestFactory.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsNull_WhenGameClientHasNoSpecializedFactory()
+ {
+ // Arrange
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [gitHubFactory],
+ NullLogger.Instance);
+
+ var gameClientManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.unknownpublisher.gameclient.generals"),
+ ContentType = ContentType.GameClient,
+ Publisher = new PublisherInfo
+ {
+ Name = "UnknownPublisher",
+ PublisherType = "unknownpublisher",
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(gameClientManifest);
+
+ // Assert
+ Assert.Null(result);
+ }
+}
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
new file mode 100644
index 000000000..2b645c6e5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
@@ -0,0 +1,497 @@
+using System;
+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.GitHub;
+using GenHub.Core.Interfaces.Providers;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GitHub;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.Content.Services.Publishers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.Publishers;
+
+///
+/// Unit tests for .
+///
+public class SuperHackersProviderTests
+{
+ private readonly Mock _providerDefinitionLoaderMock;
+ private readonly Mock _gitHubApiClientMock;
+ private readonly Mock _resolverMock;
+ private readonly Mock _delivererMock;
+ private readonly Mock _validatorMock;
+ private readonly SuperHackersProvider _provider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SuperHackersProviderTests()
+ {
+ _providerDefinitionLoaderMock = new Mock();
+ _gitHubApiClientMock = new Mock();
+ _resolverMock = new Mock();
+ _delivererMock = new Mock();
+ _validatorMock = new Mock();
+
+ _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId);
+ _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer);
+
+ _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ValidationResult("test", []));
+
+ _provider = new SuperHackersProvider(
+ _providerDefinitionLoaderMock.Object,
+ _gitHubApiClientMock.Object,
+ [_resolverMock.Object],
+ [_delivererMock.Object],
+ _validatorMock.Object,
+ NullLogger.Instance);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns both GeneralsGameCode and GeneralsGamePatch2 releases when available.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DiscoversBothGameCodeAndGamePatch2_WhenBothAvailableAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease
+ {
+ TagName = "weekly-2026-08-01",
+ Name = "Weekly Release 2026-08-01",
+ Body = "Generals and Zero Hour game code updates",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGameCode/releases/tag/weekly-2026-08-01",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Release 1.0.0",
+ Body = "Community Patch 2 to fix and improve Generals and Zero Hour",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Equal(2, items.Count);
+
+ var gameCodeItem = items.FirstOrDefault(i => i.ContentType == ContentType.GameClient);
+ Assert.NotNull(gameCodeItem);
+ Assert.Equal("weekly-2026-08-01", gameCodeItem.Version);
+ Assert.Equal(SuperHackersConstants.GeneralsGameCodeRepo, gameCodeItem.ResolverMetadata[GitHubConstants.RepoMetadataKey]);
+
+ var gamePatch2Item = items.FirstOrDefault(i => i.ContentType == ContentType.Patch);
+ Assert.NotNull(gamePatch2Item);
+ Assert.Equal("1.0.0", gamePatch2Item.Version);
+ Assert.Equal(SuperHackersConstants.GeneralsGamePatch2Repo, gamePatch2Item.ResolverMetadata[GitHubConstants.RepoMetadataKey]);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters properly by repository search term.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersBySearchTerm_CorrectlyAsync()
+ {
+ // Arrange
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Release 1.0.0",
+ Body = "Community Patch 2",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" });
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { SearchTerm = "GeneralsGamePatch2" };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by ContentType correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByContentType_ReturnsOnlyMatchingReleasesAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+ var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ Assert.Equal("1.0.0", items[0].Version);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by TargetGame correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByTargetGame_ReturnsMatchingReleasesAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+ var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var zeroHourQuery = new ContentSearchQuery { TargetGame = GameType.ZeroHour };
+
+ // Act
+ var result = await _provider.SearchAsync(zeroHourQuery);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ Assert.Equal(GameType.ZeroHour, items[0].TargetGame);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by author name and github author correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByAuthor_ReturnsEmptyWhenAuthorDoesNotMatchAsync()
+ {
+ // Arrange
+ var query = new ContentSearchQuery { AuthorName = "NonExistentAuthor" };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Empty(items);
+ }
+
+ ///
+ /// Verifies that SearchAsync matches on display name and body text.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_MatchesSearchTerm_OnDisplayNameAndBodyAsync()
+ {
+ // Arrange
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Patch Release",
+ Body = "Community patch details",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1", Body = "Engine updates" });
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { SearchTerm = SuperHackersConstants.GeneralsGamePatch2DisplayName };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns failure when one target returns null release and the other throws an error.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_WhenOneTargetReturnsNullAndOtherErrors_ReturnsFailureAsync()
+ {
+ // Arrange
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("API rate limit"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Search failed for SuperHackers targets", result.FirstError);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns successful results when one repository fails.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_ReturnsRemainingReleases_WhenOneRepositoryFailsAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("API error"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.GameClient, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns failure when all matching repositories fail.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_ReturnsFailure_WhenAllRepositoriesFailAsync()
+ {
+ // Arrange
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Network failure 1"));
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Network failure 2"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Search failed for SuperHackers targets", result.FirstError);
+ }
+
+ ///
+ /// Verifies that SearchAsync propagates cancellation.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_PropagatesCancellation_WhenCancellationRequestedAsync()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Assert.ThrowsAnyAsync(
+ () => _provider.SearchAsync(new ContentSearchQuery(), cts.Token));
+ }
+
+ ///
+ /// Verifies that SearchAsync falls back to display name and tag name when release name is blank.
+ ///
+ /// The candidate release name to test.
+ /// A representing the asynchronous operation.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task SearchAsync_UsesFallbackName_WhenReleaseNameIsBlankAsync(string? releaseName)
+ {
+ // Arrange
+ var release = new GitHubRelease
+ {
+ TagName = "alpha-4",
+ Name = releaseName ?? string.Empty,
+ Body = "Patch notes",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(release);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal($"{SuperHackersConstants.GeneralsGamePatch2DisplayName} alpha-4", items[0].Name);
+ }
+
+ ///
+ /// Verifies that SearchAsync preserves the original release name when it is not blank.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_PreservesReleaseName_WhenReleaseNameIsNonBlankAsync()
+ {
+ // Arrange
+ var release = new GitHubRelease
+ {
+ TagName = "alpha-4",
+ Name = "Community Patch 2.0 Alpha 4",
+ Body = "Patch notes",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(release);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("Community Patch 2.0 Alpha 4", items[0].Name);
+ }
+}
diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs
index 9eff496f6..db8eb5745 100644
--- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs
+++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs
@@ -278,7 +278,11 @@ public List GetGitHubDiscoveryRepositories()
settings.GitHubDiscoveryRepositories != null && settings.GitHubDiscoveryRepositories.Count > 0)
return settings.GitHubDiscoveryRepositories;
- return ["TheSuperHackers/GeneralsGameCode"];
+ return
+ [
+ $"{SuperHackersConstants.GeneralsGameCodeOwner}/{SuperHackersConstants.GeneralsGameCodeRepo}",
+ $"{SuperHackersConstants.GeneralsGamePatch2Owner}/{SuperHackersConstants.GeneralsGamePatch2Repo}",
+ ];
}
///
diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs
index 1a2a4b154..53cdf9437 100644
--- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs
+++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs
@@ -108,7 +108,7 @@ public async Task>> SearchAsync
_logger.LogDebug("Starting orchestrated content search with query: {SearchTerm}, ContentType: {ContentType}", query.SearchTerm, query.ContentType);
// Check cache first
- var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.Skip}::{query.Take}::{query.SortOrder}";
+ var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.TargetGame}::{query.AuthorName}::{query.GitHubAuthor}::{query.Language}::{query.Skip}::{query.Take}::{query.SortOrder}";
var cachedResults = await _cache.GetAsync>(cacheKey, cancellationToken);
if (cachedResults != null)
{
@@ -187,8 +187,19 @@ public async Task>> SearchAsync
// than an exception, which would otherwise surface here as an empty successful search.
cancellationToken.ThrowIfCancellationRequested();
+ // Deduplicate results by manifest ID across providers before sorting and pagination,
+ // preferring specialized publisher providers over generic GitHub providers.
+ var deduplicatedResults = allResults
+ .GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase)
+ .Select(g => g
+ .OrderByDescending(r =>
+ !string.Equals(r.ProviderName, ContentSourceNames.GitHubDiscoverer, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(r.ProviderName, ContentSourceNames.GitHubReleasesDiscoverer, StringComparison.OrdinalIgnoreCase) ? 1 : 0)
+ .First())
+ .ToList();
+
// Apply orchestrator-level sorting and pagination
- var sortedResults = ApplySorting(allResults, query.SortOrder)
+ var sortedResults = ApplySorting(deduplicatedResults, query.SortOrder)
.Skip(query.Skip)
.Take(query.Take)
.ToList();
diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs
index f91075390..e7ac16dc3 100644
--- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs
+++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs
@@ -186,6 +186,11 @@ await manifest.AddRemoteFileAsync(
}
var builtManifest = manifest.Build();
+ if (!string.IsNullOrEmpty(release.TagName))
+ {
+ builtManifest.Version = release.TagName;
+ }
+
logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id);
return OperationResult.CreateSuccess(builtManifest);
}
@@ -373,6 +378,11 @@ await manifest.AddRemoteFileAsync(
logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name);
var builtManifest = manifest.Build();
+ if (!string.IsNullOrEmpty(tag))
+ {
+ builtManifest.Version = tag;
+ }
+
logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id);
return OperationResult.CreateSuccess(builtManifest);
}
diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs
index 411041ef5..0d62e14ec 100644
--- a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs
+++ b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.Linq;
-using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using Microsoft.Extensions.Logging;
@@ -28,14 +28,30 @@ public class PublisherManifestFactoryResolver(IEnumerable().FirstOrDefault();
+ if (fallbackFactory != null)
+ {
+ logger.LogInformation(
+ "Resolved fallback {FactoryType} for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})",
+ fallbackFactory.GetType().Name,
+ manifest.Id,
+ manifest.Publisher?.PublisherType ?? "unknown",
+ manifest.ContentType);
+ return fallbackFactory;
+ }
+ }
+
logger.LogWarning(
"No factory found for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})",
manifest.Id,
- manifest.Publisher?.PublisherType ?? GameClientConstants.UnknownVersion,
+ manifest.Publisher?.PublisherType ?? "unknown",
manifest.ContentType);
return null;
diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
index b7bbf93a2..3f1b78008 100644
--- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
+++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs
@@ -71,57 +71,98 @@ public override async Task>> Se
{
try
{
+ cancellationToken.ThrowIfCancellationRequested();
var results = new List();
+ var errors = new List();
- // Directly fetch latest release from TheSuperHackers/GeneralsGameCode
- var latestRelease = await gitHubApiClient.GetLatestReleaseAsync(
- SuperHackersConstants.GeneralsGameCodeOwner,
- SuperHackersConstants.GeneralsGameCodeRepo,
- cancellationToken);
+ var targets = new (string Owner, string Repo, ContentType ContentType, GameType? TargetGame, string DisplayName)[]
+ {
+ (SuperHackersConstants.GeneralsGameCodeOwner, SuperHackersConstants.GeneralsGameCodeRepo, ContentType.GameClient, GameType.Generals, SuperHackersConstants.PublisherName),
+ (SuperHackersConstants.GeneralsGamePatch2Owner, SuperHackersConstants.GeneralsGamePatch2Repo, ContentType.Patch, null, SuperHackersConstants.GeneralsGamePatch2DisplayName),
+ };
- if (latestRelease != null &&
- (string.IsNullOrWhiteSpace(query.AuthorName) ||
- query.AuthorName.Equals(SuperHackersConstants.GeneralsGameCodeOwner, StringComparison.OrdinalIgnoreCase)) &&
- (string.IsNullOrWhiteSpace(query.SearchTerm) ||
- latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true ||
- SuperHackersConstants.GeneralsGameCodeRepo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase)))
+ var matchingTargets = targets.Where(t =>
+ (!query.ContentType.HasValue || query.ContentType.Value == t.ContentType) &&
+ (!query.TargetGame.HasValue || t.TargetGame == null || query.TargetGame.Value == t.TargetGame.Value) &&
+ (string.IsNullOrWhiteSpace(query.AuthorName) || query.AuthorName.Equals(t.Owner, StringComparison.OrdinalIgnoreCase)) &&
+ (string.IsNullOrWhiteSpace(query.GitHubAuthor) || query.GitHubAuthor.Equals(t.Owner, StringComparison.OrdinalIgnoreCase))).ToList();
+
+ foreach (var (owner, repo, contentType, targetGame, displayName) in matchingTargets)
{
- // Generate manifest ID
- var manifestId = ManifestIdGenerator.GenerateGitHubContentId(
- SuperHackersConstants.GeneralsGameCodeOwner,
- SuperHackersConstants.GeneralsGameCodeRepo,
- ContentType.GameClient,
- latestRelease.TagName);
-
- var result = new ContentSearchResult
+ try
{
- Id = manifestId,
- Name = latestRelease.Name ?? $"{SuperHackersConstants.PublisherName} {latestRelease.TagName}",
- Description = latestRelease.Body ?? "SuperHackers release - details available after resolution",
- Version = latestRelease.TagName ?? "latest",
- AuthorName = SuperHackersConstants.GeneralsGameCodeOwner,
- ContentType = ContentType.GameClient,
- TargetGame = GameType.Generals, // Simplification, could infer
- IsInferred = false,
- ProviderName = SourceName,
- RequiresResolution = true,
- ResolverId = SuperHackersConstants.ResolverId,
- SourceUrl = latestRelease.HtmlUrl,
- LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime,
- ResolverMetadata =
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var latestRelease = await gitHubApiClient.GetLatestReleaseAsync(
+ owner,
+ repo,
+ cancellationToken);
+
+ if (latestRelease != null &&
+ (string.IsNullOrWhiteSpace(query.SearchTerm) ||
+ latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true ||
+ repo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) ||
+ displayName.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) ||
+ latestRelease.Body?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true))
{
- [GitHubConstants.OwnerMetadataKey] = SuperHackersConstants.GeneralsGameCodeOwner,
- [GitHubConstants.RepoMetadataKey] = SuperHackersConstants.GeneralsGameCodeRepo,
- [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest",
- },
- };
-
- result.SetData(latestRelease);
- results.Add(result);
+ var manifestId = ManifestIdGenerator.GenerateGitHubContentId(
+ owner,
+ repo,
+ contentType,
+ latestRelease.TagName);
+
+ var resolvedTargetGame = targetGame ?? query.TargetGame ?? GameType.Unknown;
+
+ var result = new ContentSearchResult
+ {
+ Id = manifestId,
+ Name = !string.IsNullOrWhiteSpace(latestRelease.Name) ? latestRelease.Name : $"{displayName} {latestRelease.TagName}",
+ Description = latestRelease.Body ?? "SuperHackers release - details available after resolution",
+ Version = latestRelease.TagName ?? "latest",
+ AuthorName = owner,
+ ContentType = contentType,
+ TargetGame = resolvedTargetGame,
+ IsInferred = false,
+ ProviderName = SourceName,
+ RequiresResolution = true,
+ ResolverId = SuperHackersConstants.ResolverId,
+ SourceUrl = latestRelease.HtmlUrl,
+ LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime,
+ ResolverMetadata =
+ {
+ [GitHubConstants.OwnerMetadataKey] = owner,
+ [GitHubConstants.RepoMetadataKey] = repo,
+ [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest",
+ },
+ };
+
+ result.SetData(latestRelease);
+ results.Add(result);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to fetch SuperHackers release for {Owner}/{Repo}", owner, repo);
+ errors.Add($"{owner}/{repo}: {ex.Message}");
+ }
+ }
+
+ if (results.Count == 0 && errors.Count > 0)
+ {
+ return OperationResult>.CreateFailure(
+ $"Search failed for SuperHackers targets: {string.Join("; ", errors)}");
}
return OperationResult>.CreateSuccess(results);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (Exception ex)
{
Logger.LogError(ex, "Failed to search SuperHackers content");
From cbfa3c446831c449ccac5377a201b29d29d44c3b Mon Sep 17 00:00:00 2001
From: Undead <110314402+undead2146@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:46:01 +0200
Subject: [PATCH 4/6] feat(windows): add genhub:// URI scheme registrar and
command line parser (#397)
---
.../Constants/CommandLineConstants.cs | 22 +-
GenHub/GenHub.Core/Constants/IpcCommands.cs | 3 +-
.../GenHub.Core/Helpers/CommandLineParser.cs | 45 +++-
.../Helpers/CommandLineParserTests.cs | 234 ++++++++++++++++++
.../ContentReconciliationServiceTests.cs | 8 +-
.../Shortcuts/UriSchemeRegistrarTests.cs | 178 +++++++++++++
.../Shortcuts/WindowsRegistryCollection.cs | 15 ++
.../Features/Shortcuts/UriSchemeRegistrar.cs | 93 +++++++
GenHub/GenHub.Windows/Program.cs | 8 +-
GenHub/GenHub/App.axaml.cs | 93 ++++++-
.../CommunityOutpostDeliverer.cs | 2 +-
.../Services/GitHub/GitHubContentDeliverer.cs | 2 +-
global.json | 2 +-
13 files changed, 673 insertions(+), 32 deletions(-)
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs
create mode 100644 GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs
diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
index 4b0821443..30cd69c4f 100644
--- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
@@ -1,8 +1,13 @@
namespace GenHub.Core.Constants;
///
-/// Constants for command line arguments and URI schemes.
+/// Constants for command line arguments and the genhub:// URI scheme.
///
+///
+/// Subscription links use genhub://subscribe?url=<absolute-url>.
+/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share
+/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time.
+///
public static class CommandLineConstants
{
///
@@ -16,22 +21,27 @@ public static class CommandLineConstants
public const string LaunchProfileInlinePrefix = "--launch-profile=";
///
- /// URI scheme used for protocol handling.
+ /// Scheme name for custom protocol registration.
///
- public const string UriScheme = "genhub://";
+ public const string SchemeName = "genhub";
///
- /// Command for subscribing to a catalog via URI.
+ /// Custom URI scheme registered so OS/browser links can open GenHub.
+ ///
+ public const string UriScheme = SchemeName + "://";
+
+ ///
+ /// URI path segment for content subscription (genhub://subscribe?url=...).
///
public const string SubscribeCommand = "subscribe";
///
- /// Full prefix for subscription URI.
+ /// Full prefix for subscription URIs (genhub://subscribe).
///
public const string SubscribeUriPrefix = UriScheme + SubscribeCommand;
///
- /// Query parameter name for the catalog URL in a subscription URI.
+ /// Query parameter carrying the absolute URL of a catalog (or future provider definition).
///
public const string SubscribeUrlParam = "?url=";
}
diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs
index 1a66630f8..4096fd317 100644
--- a/GenHub/GenHub.Core/Constants/IpcCommands.cs
+++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs
@@ -11,7 +11,8 @@ public static class IpcCommands
public const string LaunchProfilePrefix = "launch-profile:";
///
- /// Command prefix used to subscribe to a catalog via IPC.
+ /// Command prefix used to forward a subscribe URL to the primary instance
+ /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=....
///
public const string SubscribePrefix = "subscribe:";
}
diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
index f6b570af0..d5c595d36 100644
--- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
+++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
@@ -15,9 +15,9 @@ public static class CommandLineParser
/// The extracted profile identifier if present; otherwise, null.
public static string? ExtractProfileId(string[] args)
{
- for (var i = 0; i < args.Length; i++)
+ for (int i = 0; i < args.Length; i++)
{
- var arg = args[i];
+ string arg = args[i];
if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
@@ -34,23 +34,48 @@ public static class CommandLineParser
}
///
- /// Extracts a subscription URL from command line arguments.
- /// Supports the URI scheme format: genhub://subscribe?url=<url>.
+ /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument.
///
+ ///
+ /// The returned value is the url query value only (not the genhub:// wrapper).
+ /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider
+ /// Definition URL without changing this parser.
+ ///
/// The command line arguments.
- /// The extracted catalog URL if present; otherwise, null.
+ /// The decoded absolute URL if present; otherwise, null.
public static string? ExtractSubscriptionUrl(string[] args)
{
- foreach (var arg in args)
+ foreach (string arg in args)
{
if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase))
{
- // Simple parsing for ?url=...
- var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
+ string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..];
+ if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
if (queryStart != -1)
{
- var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
- return Uri.UnescapeDataString(url).Trim('"');
+ string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
+ string unescaped = Uri.UnescapeDataString(url)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim('"', '\'', ' ', '\t');
+
+ if (string.IsNullOrWhiteSpace(unescaped))
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return unescaped;
+ }
+
+ return null;
}
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs
new file mode 100644
index 000000000..d9d9fa997
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs
@@ -0,0 +1,234 @@
+using System;
+using GenHub.Core.Helpers;
+using Xunit;
+
+namespace GenHub.Tests.Core.Helpers;
+
+///
+/// Unit tests for .
+///
+public sealed class CommandLineParserTests
+{
+ ///
+ /// Verifies that ExtractProfileId correctly extracts profile id from spaced argument.
+ ///
+ [Fact]
+ public void ExtractProfileId_WithSpacedArgument_ReturnsProfileId()
+ {
+ var args = new[] { "--other", "value", "--launch-profile", "test-profile-123" };
+
+ var result = CommandLineParser.ExtractProfileId(args);
+
+ Assert.Equal("test-profile-123", result);
+ }
+
+ ///
+ /// Verifies that ExtractProfileId correctly extracts profile id from inline argument.
+ ///
+ [Fact]
+ public void ExtractProfileId_WithInlineArgument_ReturnsProfileId()
+ {
+ var args = new[] { "--launch-profile=test-profile-456" };
+
+ var result = CommandLineParser.ExtractProfileId(args);
+
+ Assert.Equal("test-profile-456", result);
+ }
+
+ ///
+ /// Verifies that ExtractProfileId trims surrounding quotes.
+ ///
+ [Fact]
+ public void ExtractProfileId_WithQuotedValues_ReturnsTrimmedProfileId()
+ {
+ var argsSpaced = new[] { "--launch-profile", "\"quoted-profile\"" };
+ var argsInline = new[] { "--launch-profile=\"quoted-profile\"" };
+
+ Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsSpaced));
+ Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsInline));
+ }
+
+ ///
+ /// Verifies that ExtractProfileId returns null when launch profile argument is absent.
+ ///
+ [Fact]
+ public void ExtractProfileId_WhenMissing_ReturnsNull()
+ {
+ var args = new[] { "--verbose", "--other" };
+
+ var result = CommandLineParser.ExtractProfileId(args);
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ExtractProfileId returns null when spaced argument has no subsequent value.
+ ///
+ [Fact]
+ public void ExtractProfileId_WhenFlagAtEndWithoutValue_ReturnsNull()
+ {
+ var args = new[] { "--launch-profile" };
+
+ var result = CommandLineParser.ExtractProfileId(args);
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl parses direct catalog URLs.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithDirectUrl_ReturnsDecodedUrl()
+ {
+ var args = new[] { "genhub://subscribe?url=https://example.com/catalog.json" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/catalog.json", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl correctly decodes URL encoded parameters.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithUrlEncodedParameter_ReturnsDecodedUrl()
+ {
+ var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%3Fversion%3D1" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/catalog.json?version=1", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl trims quotes around the url value.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithQuotedArgument_ReturnsTrimmedUrl()
+ {
+ var argsClean = new[] { "genhub://subscribe?url=\"https://example.com/catalog.json\"" };
+
+ Assert.Equal("https://example.com/catalog.json", CommandLineParser.ExtractSubscriptionUrl(argsClean));
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns null when no subscribe URI is present.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WhenNotPresent_ReturnsNull()
+ {
+ var args = new[] { "--launch-profile", "test" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl is case insensitive with protocol prefix and query parameter.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_CaseInsensitivePrefix_ReturnsUrl()
+ {
+ var args = new[] { "GENHUB://SUBSCRIBE?URL=https://example.com/catalog.json" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/catalog.json", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns null when subscribe URI lacks the url query parameter.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithoutUrlParameter_ReturnsNull()
+ {
+ var args = new[] { "genhub://subscribe" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns null when the url query parameter is empty.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithEmptyUrlParameter_ReturnsNull()
+ {
+ var args = new[] { "genhub://subscribe?url=" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl extracts the URL even when preceded by other arguments.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WhenNotFirstArgument_ReturnsUrl()
+ {
+ var args = new[] { "--verbose", "--launch-profile", "test-profile", "genhub://subscribe?url=https://example.com/catalog.json" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/catalog.json", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns the first matching subscription URL when multiple are present.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_MultipleUrls_ReturnsFirstMatch()
+ {
+ var args = new[]
+ {
+ "genhub://subscribe?url=https://example.com/first.json",
+ "genhub://subscribe?url=https://example.com/second.json",
+ };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/first.json", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns null for non-HTTP and non-HTTPS URI schemes.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_NonHttpOrHttpsScheme_ReturnsNull()
+ {
+ var fileSchemeArgs = new[] { "genhub://subscribe?url=file:///C:/malicious.exe" };
+ var jsSchemeArgs = new[] { "genhub://subscribe?url=javascript:alert(1)" };
+
+ Assert.Null(CommandLineParser.ExtractSubscriptionUrl(fileSchemeArgs));
+ Assert.Null(CommandLineParser.ExtractSubscriptionUrl(jsSchemeArgs));
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl strips newlines and control characters from the URL.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithNewlinesAndControlChars_ReturnsSanitizedUrl()
+ {
+ var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%0D%0A" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Equal("https://example.com/catalog.json", result);
+ }
+
+ ///
+ /// Verifies that ExtractSubscriptionUrl returns null for non-command subscribe-prefixed URIs.
+ ///
+ [Fact]
+ public void ExtractSubscriptionUrl_WithNonCommandSubscribePrefixedUri_ReturnsNull()
+ {
+ var args = new[] { "genhub://subscribe-anything?url=https://example.com/catalog.json" };
+
+ var result = CommandLineParser.ExtractSubscriptionUrl(args);
+
+ Assert.Null(result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
index b203ee30f..6de3d7e9a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -81,8 +80,6 @@ public ContentReconciliationServiceTests()
///
/// A representing the asynchronous unit test.
[Fact]
- [SuppressMessage("DeepSource", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")]
- [SuppressMessage("csharp", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")]
public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToPool_AndUpdateProfilesAsync()
{
// Arrange
@@ -129,7 +126,7 @@ public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToP
_profileManagerMock.Verify(
x => x.UpdateProfileAsync(
"profile-1",
- It.Is(r => r.GameClient != null && r.GameClient.Id == newId),
+ It.Is(r => MatchesGameClientId(r, newId)),
It.IsAny()),
Times.Once,
"Should update profile with new manifest ID");
@@ -283,4 +280,7 @@ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailureAsyn
result.FirstError.Should().Be(
GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage);
}
+
+ private static bool MatchesGameClientId(UpdateProfileRequest request, string expectedId) =>
+ request.GameClient?.Id == expectedId;
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs
new file mode 100644
index 000000000..529be0d13
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.Versioning;
+using GenHub.Windows.Features.Shortcuts;
+using Microsoft.Win32;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace GenHub.Tests.Windows.Features.Shortcuts;
+
+///
+/// Unit tests for .
+///
+/// Output helper for surfacing test diagnostic messages.
+[Collection(WindowsRegistryCollection.Name)]
+[SupportedOSPlatform("windows")]
+public sealed class UriSchemeRegistrarTests(ITestOutputHelper testOutputHelper) : IDisposable
+{
+ private const string TargetKeyPath = @"Software\Classes\genhub";
+ private readonly RegistryKeySnapshot? _snapshot = CaptureInitialSnapshot();
+ private readonly bool _existedPrior = KeyExists();
+
+ ///
+ /// Verifies that Register creates or updates the genhub registry keys in HKCU.
+ ///
+ [Fact]
+ public void Register_CreatesOrUpdatesGenhubRegistryKey()
+ {
+ // Act
+ UriSchemeRegistrar.Register();
+
+ // Assert
+ using var key = Registry.CurrentUser.OpenSubKey(TargetKeyPath);
+ Assert.NotNull(key);
+
+ var protocolValue = key.GetValue(string.Empty) as string;
+ Assert.Equal("URL:genhub protocol", protocolValue);
+
+ var urlProtocolFlag = key.GetValue("URL Protocol");
+ Assert.NotNull(urlProtocolFlag);
+
+ using var commandKey = Registry.CurrentUser.OpenSubKey($@"{TargetKeyPath}\shell\open\command");
+ Assert.NotNull(commandKey);
+
+ var command = commandKey.GetValue(string.Empty) as string;
+ Assert.NotNull(command);
+ Assert.Contains("%1", command);
+ Assert.Contains(Environment.ProcessPath ?? string.Empty, command, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that Register can be invoked repeatedly without failure or unexpected mutations.
+ ///
+ [Fact]
+ public void Register_IsIdempotent()
+ {
+ // Act - Call twice in succession to ensure no exceptions or unintended side effects occur
+ UriSchemeRegistrar.Register();
+ var ex = Record.Exception(() => UriSchemeRegistrar.Register());
+
+ // Assert
+ Assert.Null(ex);
+ }
+
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (_existedPrior && _snapshot != null)
+ {
+ using var rootKey = Registry.CurrentUser.CreateSubKey(TargetKeyPath, writable: true);
+ if (rootKey != null)
+ {
+ RestoreSnapshot(rootKey, _snapshot);
+ }
+ }
+ else
+ {
+ Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false);
+ }
+ }
+ catch (Exception ex)
+ {
+ testOutputHelper.WriteLine($"Failed to restore registry snapshot during test teardown: {ex.Message}");
+ }
+ }
+
+ private static bool KeyExists()
+ {
+ using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false);
+ return rootKey != null;
+ }
+
+ private static RegistryKeySnapshot? CaptureInitialSnapshot()
+ {
+ using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false);
+ return rootKey != null ? CaptureSnapshot(rootKey) : null;
+ }
+
+ private static RegistryKeySnapshot CaptureSnapshot(RegistryKey key)
+ {
+ var snapshot = new RegistryKeySnapshot
+ {
+ Name = Path.GetFileName(key.Name),
+ };
+
+ foreach (var valueName in key.GetValueNames())
+ {
+ var value = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
+ var kind = key.GetValueKind(valueName);
+ snapshot.Values[valueName] = (value, kind);
+ }
+
+ foreach (var subKeyName in key.GetSubKeyNames())
+ {
+ using var subKey = key.OpenSubKey(subKeyName, writable: false);
+ if (subKey != null)
+ {
+ snapshot.SubKeys.Add(CaptureSnapshot(subKey));
+ }
+ }
+
+ return snapshot;
+ }
+
+ private static void RestoreSnapshot(RegistryKey targetKey, RegistryKeySnapshot snapshot)
+ {
+ // Delete values not present in snapshot
+ foreach (var valueName in targetKey.GetValueNames())
+ {
+ if (!snapshot.Values.ContainsKey(valueName))
+ {
+ targetKey.DeleteValue(valueName, throwOnMissingValue: false);
+ }
+ }
+
+ // Restore values
+ foreach (var (valueName, (value, kind)) in snapshot.Values)
+ {
+ if (value != null)
+ {
+ targetKey.SetValue(valueName, value, kind);
+ }
+ }
+
+ // Delete subkeys not present in snapshot
+ var snapshotSubKeyNames = new HashSet(snapshot.SubKeys.Select(s => s.Name), StringComparer.OrdinalIgnoreCase);
+ foreach (var subKeyName in targetKey.GetSubKeyNames())
+ {
+ if (!snapshotSubKeyNames.Contains(subKeyName))
+ {
+ targetKey.DeleteSubKeyTree(subKeyName, throwOnMissingSubKey: false);
+ }
+ }
+
+ // Restore subkeys recursively
+ foreach (var subKeySnapshot in snapshot.SubKeys)
+ {
+ using var subKey = targetKey.CreateSubKey(subKeySnapshot.Name, writable: true);
+ if (subKey != null)
+ {
+ RestoreSnapshot(subKey, subKeySnapshot);
+ }
+ }
+ }
+
+ private sealed class RegistryKeySnapshot
+ {
+ public string Name { get; set; } = string.Empty;
+
+ public Dictionary Values { get; } = [];
+
+ public List SubKeys { get; } = [];
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs
new file mode 100644
index 000000000..23847849f
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs
@@ -0,0 +1,15 @@
+using Xunit;
+
+namespace GenHub.Tests.Windows.Features.Shortcuts;
+
+///
+/// Prevents registry tests from overlapping and racing.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public class WindowsRegistryCollection
+{
+ ///
+ /// The xUnit collection name.
+ ///
+ public const string Name = "Windows registry";
+}
diff --git a/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs
new file mode 100644
index 000000000..1917cf585
--- /dev/null
+++ b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs
@@ -0,0 +1,93 @@
+using System;
+using System.IO;
+using GenHub.Core.Constants;
+using Microsoft.Extensions.Logging;
+using Microsoft.Win32;
+
+namespace GenHub.Windows.Features.Shortcuts;
+
+///
+/// Registers the genhub:// URI scheme with Windows so OS/browser links open GenHub.
+///
+///
+///
+/// Windows resolves custom protocols through HKCU\Software\Classes\<scheme>. Without
+/// that key the shell shows an "app not installed" dialog when a genhub:// link is clicked.
+/// The app already parses genhub://subscribe?url=... from its own command line
+/// (GenHub.Core.Helpers.CommandLineParser.ExtractSubscriptionUrl); this registrar wires the
+/// OS shell to that path.
+///
+///
+/// Writes to HKCU (per-user), so no elevation is required. The registration is idempotent
+/// and self-repairs: it rewrites the command only when the executable path has changed, which is
+/// what happens every time a debug rebuild or Velopack update lands at a new path.
+///
+///
+public static class UriSchemeRegistrar
+{
+ private const string SchemeName = CommandLineConstants.SchemeName;
+ private const string ClassesSubKey = @"Software\Classes\" + SchemeName;
+
+ ///
+ /// Registers the genhub:// scheme for the current user, pointing at the running
+ /// executable. Safe to call on every launch.
+ ///
+ /// Optional logger for diagnostics.
+ public static void Register(ILogger? logger = null)
+ {
+ var executablePath = Environment.ProcessPath;
+ if (string.IsNullOrEmpty(executablePath) || !File.Exists(executablePath))
+ {
+ logger?.LogWarning("Could not register genhub:// scheme: executable path unavailable.");
+ return;
+ }
+
+ try
+ {
+ var desiredCommand = $"\"{executablePath}\" \"%1\"";
+ var desiredProtocol = $"URL:{SchemeName} protocol";
+ var desiredIcon = $"{executablePath},0";
+
+ // Check if already registered and up-to-date before performing any writes
+ using (var existingClassesKey = Registry.CurrentUser.OpenSubKey(ClassesSubKey, writable: false))
+ {
+ if (existingClassesKey != null)
+ {
+ var existingProtocol = existingClassesKey.GetValue(string.Empty) as string;
+ var existingUrlProtocol = existingClassesKey.GetValue("URL Protocol");
+
+ using var existingCommandKey = existingClassesKey.OpenSubKey(@"shell\open\command", writable: false);
+ var existingCommand = existingCommandKey?.GetValue(string.Empty) as string;
+
+ if (string.Equals(existingProtocol, desiredProtocol, StringComparison.OrdinalIgnoreCase) &&
+ existingUrlProtocol != null &&
+ string.Equals(existingCommand, desiredCommand, StringComparison.OrdinalIgnoreCase))
+ {
+ logger?.LogDebug("genhub:// scheme is already registered and up-to-date.");
+ return;
+ }
+ }
+ }
+
+ using var classesKey = Registry.CurrentUser.CreateSubKey(ClassesSubKey, writable: true);
+
+ // URL Protocol flag tells the shell this is a URI handler, not a normal file type.
+ classesKey.SetValue(string.Empty, desiredProtocol);
+ classesKey.SetValue("URL Protocol", string.Empty);
+
+ using var iconKey = classesKey.CreateSubKey("DefaultIcon");
+ iconKey.SetValue(string.Empty, desiredIcon);
+
+ using var commandKey = classesKey.CreateSubKey(@"shell\open\command");
+ commandKey.SetValue(string.Empty, desiredCommand);
+
+ logger?.LogInformation("Registered genhub:// scheme -> {ExecutablePath}", executablePath);
+ }
+ catch (Exception ex)
+ {
+ // Registration failure must never block app startup; the in-app subscribe paths still
+ // work via direct command-line invocation.
+ logger?.LogWarning(ex, "Failed to register genhub:// scheme.");
+ }
+ }
+}
diff --git a/GenHub/GenHub.Windows/Program.cs b/GenHub/GenHub.Windows/Program.cs
index 031e8108f..996834a0d 100644
--- a/GenHub/GenHub.Windows/Program.cs
+++ b/GenHub/GenHub.Windows/Program.cs
@@ -52,7 +52,7 @@ public static void Main(string[] args)
// Extract profile ID from args if present (for IPC forwarding)
var profileId = CommandLineParser.ExtractProfileId(args);
- // Extract subscription URL from args if present (for IPC forwarding)
+ // Extract genhub://subscribe?url=... target (catalog JSON today; definition URL later)
var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args);
// Check for multi-instance mode (useful for debugging with multiple instances)
@@ -74,7 +74,7 @@ public static void Main(string[] args)
SingleInstanceManager.SendCommandToPrimaryInstance($"{IpcCommands.LaunchProfilePrefix}{profileId}");
}
- // Forward subscribe command to primary instance if we have a subscription URL
+ // Forward subscribe so the running UI can show the confirmation dialog
if (!string.IsNullOrEmpty(subscriptionUrl))
{
bootstrapLogger.LogInformation("Forwarding subscribe command to primary instance: {Url}", subscriptionUrl);
@@ -94,6 +94,10 @@ public static void Main(string[] args)
bootstrapLogger.LogInformation("Multi-instance mode enabled - skipping single-instance check");
}
+ // Register the genhub:// URI scheme with Windows so clicked links open this executable.
+ // Registered for primary instance only; idempotent and per-user (HKCU).
+ Features.Shortcuts.UriSchemeRegistrar.Register(bootstrapLogger);
+
try
{
bootstrapLogger.LogInformation("Starting GenHub Windows application");
diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs
index 3017451f0..a2f92fc64 100644
--- a/GenHub/GenHub/App.axaml.cs
+++ b/GenHub/GenHub/App.axaml.cs
@@ -11,6 +11,8 @@
using GenHub.Core.Helpers;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.GameProfiles;
+using GenHub.Core.Interfaces.Notifications;
+using GenHub.Core.Models.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -65,8 +67,8 @@ public override void OnFrameworkInitializationCompleted()
// Subscribe to IPC commands from secondary instances (Windows only)
SubscribeToSingleInstanceCommands(mainWindow);
- // Handle launch profile from startup args (first launch with shortcut)
- SafeFireAndForget(HandleLaunchProfileArgsAsync(desktop.Args, mainWindow), "HandleLaunchProfileArgsAsync");
+ // Handle startup arguments sequentially (launch profile, then subscription if present)
+ SafeFireAndForget(HandleStartupArgsAsync(desktop.Args, mainWindow), nameof(HandleStartupArgsAsync));
}
base.OnFrameworkInitializationCompleted();
@@ -168,6 +170,17 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg
}
}
+ private async Task HandleStartupArgsAsync(string[]? args, MainWindow mainWindow)
+ {
+ if (args == null || args.Length == 0)
+ {
+ return;
+ }
+
+ await HandleLaunchProfileArgsAsync(args, mainWindow);
+ await HandleSubscriptionArgsAsync(args, mainWindow);
+ }
+
private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainWindow)
{
if (args == null || args.Length == 0)
@@ -187,6 +200,25 @@ private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainW
await LaunchProfileByIdAsync(profileId, mainWindow);
}
+ private async Task HandleSubscriptionArgsAsync(string[]? args, MainWindow mainWindow)
+ {
+ if (args == null || args.Length == 0)
+ {
+ return;
+ }
+
+ var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args);
+ if (string.IsNullOrWhiteSpace(subscriptionUrl))
+ {
+ return;
+ }
+
+ var logger = _serviceProvider.GetService>();
+ logger?.LogInformation("Startup subscription detected for URL: {Url}", subscriptionUrl);
+
+ await HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow);
+ }
+
private void SubscribeToSingleInstanceCommands(MainWindow mainWindow)
{
// Get the SingleInstanceManager from AppLocator (set by Windows Program.cs)
@@ -197,10 +229,7 @@ private void SubscribeToSingleInstanceCommands(MainWindow mainWindow)
}
singleInstanceManager.CommandReceived += (_, command) =>
- {
- // Dispatch to UI thread since the event comes from a background pipe listener
Dispatcher.UIThread.Post(() => HandleSingleInstanceCommand(command, mainWindow));
- };
var logger = _serviceProvider.GetService>();
logger?.LogDebug("Subscribed to single instance IPC commands");
@@ -216,7 +245,15 @@ private void HandleSingleInstanceCommand(string command, MainWindow mainWindow)
logger?.LogInformation("Received IPC launch command for profile: {ProfileId}", profileId);
// Launch the profile
- SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), "LaunchProfileByIdAsync");
+ SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), nameof(LaunchProfileByIdAsync));
+ }
+ else if (command.StartsWith(IpcCommands.SubscribePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var subscriptionUrl = command[IpcCommands.SubscribePrefix.Length..];
+ logger?.LogInformation("Received IPC subscribe command for URL: {Url}", subscriptionUrl);
+
+ // Handle the subscription URL
+ SafeFireAndForget(HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow), nameof(HandleSubscriptionUrlAsync));
}
else
{
@@ -269,4 +306,48 @@ private async Task LaunchProfileByIdAsync(string profileId, MainWindow mainWindo
logger?.LogError(ex, "Exception while launching profile {ProfileId}", profileId);
}
}
+
+ private async Task HandleSubscriptionUrlAsync(string subscriptionUrl, MainWindow mainWindow)
+ {
+ var logger = _serviceProvider.GetService>();
+
+ try
+ {
+ var sanitizedUrl = subscriptionUrl.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim('"', '\'', ' ', '\t');
+ if (!Uri.TryCreate(sanitizedUrl, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ logger?.LogWarning("Invalid or unsafe subscription URL: {Url}", subscriptionUrl);
+ return;
+ }
+
+ logger?.LogInformation("Handling subscription URL: {Url}", uri.AbsoluteUri);
+
+ var dialogService = _serviceProvider.GetService();
+ if (dialogService != null)
+ {
+ var confirmed = await dialogService.ShowConfirmationAsync(
+ "Subscribe to Catalog",
+ $"Do you want to subscribe to content from:\n{uri.AbsoluteUri}",
+ "Subscribe",
+ "Cancel");
+
+ if (confirmed)
+ {
+ if (mainWindow?.DataContext is MainViewModel mainViewModel)
+ {
+ mainViewModel.SelectTab(NavigationTab.Downloads);
+ }
+
+ logger?.LogInformation("User confirmed subscription to: {Url}", uri.AbsoluteUri);
+ var notificationService = _serviceProvider.GetService();
+ notificationService?.ShowSuccess("Subscribed", $"Successfully subscribed to: {uri.AbsoluteUri}");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError(ex, "Exception while handling subscription URL {Url}", subscriptionUrl);
+ }
+ }
}
diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs
index be8788503..9bc5bd496 100644
--- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs
+++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs
@@ -97,7 +97,7 @@ await Task.Run(
throw new FileNotFoundException($"Archive file not found or empty: {archivePath}");
}
- using var archive = ArchiveFactory.Open(archivePath);
+ using var archive = ArchiveFactory.Open(fileInfo);
foreach (var entry in archive.Entries.Where(e => !e.IsDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs
index 1814c5362..5d8578b60 100644
--- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs
+++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs
@@ -381,7 +381,7 @@ private async Task ExtractArchiveAsync(
await Task.Run(
() =>
{
- using var archive = ArchiveFactory.Open(archiveFile);
+ using var archive = ArchiveFactory.Open(new FileInfo(archiveFile));
int totalEntries = archive.Entries.Count(e => !e.IsDirectory);
int currentEntry = 0;
diff --git a/global.json b/global.json
index 1834d84d1..da333ae07 100644
--- a/global.json
+++ b/global.json
@@ -1,7 +1,7 @@
{
"sdk": {
"version": "8.0.424",
- "rollForward": "latestFeature",
+ "rollForward": "latestMajor",
"allowPrerelease": false
}
}
From 7db9e988684769887610ffcb92316d5b93d01e93 Mon Sep 17 00:00:00 2001
From: undead2146
Date: Wed, 19 Aug 2026 07:15:47 +0200
Subject: [PATCH 5/6] feat(content): add ModDBPageParser and Playwright
headless scraper service
---
.../GenHub.Core/Constants/ContentConstants.cs | 27 +
.../GenHub.Core/Constants/DirectoryNames.cs | 10 +
.../GenHub.Core/Constants/ModDBConstants.cs | 57 +
.../Constants/ModDBParserConstants.cs | 174 +-
GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs | 145 +
.../Interfaces/Parsers/IWebPageParser.cs | 57 +
.../Interfaces/Tools/IPlaywrightService.cs | 78 +
GenHub/GenHub.Core/Models/ModDB/MapDetails.cs | 2 +-
.../Models/ModDB/ModDBCategoryMapper.cs | 38 +-
.../GenHub.Core/Models/ModDB/ModDBFilter.cs | 6 +-
GenHub/GenHub.Core/Models/Parsers/Comment.cs | 9 +-
.../Models/Parsers/DownloadableFile.cs | 42 +
.../Models/Parsers/FileSectionType.cs | 13 +
.../Content/ModDB/ModDBCategoryMapperTests.cs | 63 +
.../Content/Parsers/ModDBPageParserTests.cs | 1664 +++++++++
.../Tools/ManagedChromiumRuntimeTests.cs | 168 +
.../Helpers/HtmlTextHelperTests.cs | 147 +
.../Services/Parsers/ModDBPageParser.cs | 3244 +++++++++++++++++
.../Services/Tools/ManagedChromiumRuntime.cs | 193 +
.../Services/Tools/PlaywrightService.cs | 1355 +++++++
.../ContentPipelineModule.cs | 12 +
21 files changed, 7459 insertions(+), 45 deletions(-)
create mode 100644 GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
create mode 100644 GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs
create mode 100644 GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Tools/ManagedChromiumRuntimeTests.cs
create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs
create mode 100644 GenHub/GenHub/Features/Content/Services/Parsers/ModDBPageParser.cs
create mode 100644 GenHub/GenHub/Features/Content/Services/Tools/ManagedChromiumRuntime.cs
create mode 100644 GenHub/GenHub/Features/Content/Services/Tools/PlaywrightService.cs
diff --git a/GenHub/GenHub.Core/Constants/ContentConstants.cs b/GenHub/GenHub.Core/Constants/ContentConstants.cs
index 0bc3c907c..96d6a6db0 100644
--- a/GenHub/GenHub.Core/Constants/ContentConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ContentConstants.cs
@@ -94,4 +94,31 @@ public static class ContentConstants
/// Maximum allowed size for the content catalog in bytes (10 MB).
///
public const long MaxCatalogSizeBytes = 10 * ConversionConstants.BytesPerMegabyte;
+
+ ///
+ /// Shared resolver/display metadata key for map player counts.
+ /// Builtin and catalog publishers should set this so download cards can render a consistent badge.
+ ///
+ public const string PlayerCountMetadataKey = "playerCount";
+
+ ///
+ /// Shared resolver/display metadata key for content categories (AOA, Compstomp, ModDB category, etc.).
+ ///
+ public const string CategoryMetadataKey = "category";
+
+ ///
+ /// Display metadata key for a comma-separated list of included/required content names
+ /// (e.g. catalog ContentBundle dependencies resolved to friendly titles).
+ ///
+ public const string IncludesSummaryMetadataKey = "includesSummary";
+
+ ///
+ /// Number of recent releases and addons to eagerly preload extended details for.
+ ///
+ public const int PreloadRecentItemsLimit = 5;
+
+ ///
+ /// Maximum concurrent background requests when preloading recent item details.
+ ///
+ public const int PreloadConcurrencyLimit = 3;
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
index 47097cc18..19b341d80 100644
--- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs
+++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
@@ -59,4 +59,14 @@ public static class DirectoryNames
/// Directory for storing tool workspaces.
///
public const string ToolWorkspaces = "ToolWorkspaces";
+
+ ///
+ /// Directory for persistent Playwright browser profiles (cookies/storage for bot-protected sites).
+ ///
+ public const string BrowserProfiles = "BrowserProfiles";
+
+ ///
+ /// Directory for the app-owned Playwright Chromium runtime (not the system Chrome/Edge install).
+ ///
+ public const string BrowserRuntime = "BrowserRuntime";
}
diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
index 2deed935c..f8b9c8d3d 100644
--- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs
@@ -72,6 +72,12 @@ public static class ModDBConstants
/// ModDB website URL.
public const string PublisherWebsite = BaseUrl;
+ ///
+ /// On-disk Playwright browser profile name used to persist the Cloudflare clearance cookie so
+ /// the user only solves the bot challenge once per session (and across restarts until expiry).
+ ///
+ public const string BrowserProfileName = "moddb";
+
/// Short description for publisher card display.
public const string ShortDescription = "Community mods, maps, and content from ModDB";
@@ -184,6 +190,29 @@ public static class ModDBConstants
/// Value for filter parameter when enabled.
public const string FilterEnabledValue = "t";
+ // ===== Sort Values =====
+
+ /// Sort: Date descending (newest first).
+ public const string SortDateDesc = "date-desc";
+
+ /// Sort: Date ascending (oldest first).
+ public const string SortDateAsc = "date-asc";
+
+ /// Sort: Visits / Popularity descending.
+ public const string SortVisitDesc = "visit-desc";
+
+ /// Sort: Rating descending.
+ public const string SortRatingDesc = "rating-desc";
+
+ /// Sort: Name ascending (A-Z).
+ public const string SortNameAsc = "name-asc";
+
+ /// Sort: Name descending (Z-A).
+ public const string SortNameDesc = "name-desc";
+
+ /// Default sort value for ModDB searches and listings (newest first).
+ public const string DefaultSort = SortDateDesc;
+
// ===== Category Values =====
// Downloads Section - Releases
@@ -356,6 +385,34 @@ public static class ModDBConstants
/// Metadata key for original category.
public const string OriginalCategoryMetadataKey = "moddbCategory";
+ /// Metadata key for identifying if content is a mod.
+ public const string IsModMetadataKey = "IsMod";
+
+ /// Metadata key for parent mod URL.
+ public const string ParentModUrlMetadataKey = "ParentModUrl";
+
+ // ===== Playwright / Scraping Constants =====
+
+ /// Default timeout for page navigation (ms).
+ public const int DefaultGotoTimeout = 30000;
+
+ ///
+ /// Default timeout for waiting for a selector (ms). ModDB sits behind Cloudflare; the headed
+ /// browser persistent profile usually receives the clearance cookie after verification, but 15 s gives a safe margin
+ /// for manual challenge solves before the scraper parses whatever it has.
+ ///
+ public const int DefaultSelectorTimeout = 15000;
+
+ ///
+ /// How long (ms) the listing scrape waits for the user to solve a Cloudflare challenge in the
+ /// visible browser before giving up. Long enough for a manual "I am not a robot" click; the
+ /// page stays open after the deadline so the user can finish and retry.
+ ///
+ public const int VerificationWaitTimeoutMs = 120000;
+
+ /// Selector for content items in listing pages (Fallback).
+ public const string DefaultListItemSelector = "div.row.rowcontent, div.table tr";
+
// ===== Error Messages =====
/// Error message for invalid URL.
diff --git a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
index 55ab7fa3c..4f61403a9 100644
--- a/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ModDBParserConstants.cs
@@ -17,6 +17,9 @@ public static class ModDBParserConstants
/// Selector for developer/publisher links.
public const string DeveloperSelector = "a[href*='/members/'], a[href*='/company/']";
+ /// Selector for the profile/mod info box that carries the real developer name.
+ public const string DeveloperProfileSelector = "#modsinfo a[href*='/members/'], #modsinfo a[href*='/company/'], .sidecolumn a[href*='/members/'], .sidecolumn a[href*='/company/']";
+
/// Selector for release date.
public const string ReleaseDateSelector = "time[datetime], .date, .released";
@@ -59,7 +62,7 @@ public static class ModDBParserConstants
public const string FileMetadataValueSelector = "td:last-child";
/// Selector for the main download button on file pages.
- public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/']";
+ public const string MainDownloadButtonSelector = "a.download, a.downloadarea, .downloadbutton a, a[href*='/downloads/start/'], a[href*='/addons/start/']";
/// Selector for download size on the button.
public const string DownloadSizeSelector = ".download .size, .downloadbutton .size";
@@ -87,10 +90,13 @@ public static class ModDBParserConstants
// ===== Description/Summary Selectors =====
/// Selector for full description content.
- public const string FullDescriptionSelector = "#articlebrowse, .summary .content, .description .content, .modtext";
+ public const string FullDescriptionSelector = "#downloaddescription, #downloadsummary, #articlebrowse .articlebody, .articlebody, #modsummary, .modtext, #profile .description, #description, #articlebrowse, .summary .content, .description .content";
+
+ /// Selector for the file-page body copy (not the breadcrumb .summary trail).
+ public const string FileDescriptionSelector = "#downloaddescription, #downloadsummary";
- /// Selector for truncated summary.
- public const string SummarySelector = ".summary p, .description p";
+ /// Selector for summary or description container.
+ public const string SummarySelector = ".description, .rubric, p[itemprop='description']";
// ===== Legacy File Selectors =====
@@ -98,28 +104,31 @@ public static class ModDBParserConstants
public const string FilesTableSelector = "table.filelist, .table.files, #files";
/// Selector for individual file rows.
- public const string FileRowSelector = "tr.file, .row.file, .file";
+ public const string FileRowSelector = "tr.file, .row.file, .file, .row.rowcontent";
/// Selector for file name.
- public const string FileNameSelector = "h5, h4, .name, .title";
+ public const string FileNameSelector = "h4 a, h5 a, h3 a, .heading a, .title a, a.title, .name a, h5, h4, .name, .title";
/// Selector for file version.
public const string FileVersionSelector = ".version, .ver";
/// Selector for file size.
- public const string FileSizeSelector = ".size, .filesize";
+ public const string FileSizeSelector = ".size, .filesize, .filesizes, span.size";
+
+ /// Selector for file subheading or metadata row.
+ public const string FileSubheadingSelector = ".subheading, span.subheading, .meta, .details, .info, p.summary, .summary";
/// Selector for file upload date.
- public const string FileDateSelector = "time[datetime], .date, .uploaded";
+ public const string FileDateSelector = "time[datetime], .date, .uploaded, time";
/// Selector for file category.
- public const string FileCategorySelector = ".category, .type";
+ public const string FileCategorySelector = ".category, .type, span.category";
/// Selector for file uploader.
- public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/']";
+ public const string FileUploaderSelector = ".uploader, .author, a[href*='/members/'], a[href*='/company/']";
/// Selector for file download link (robust).
- public const string FileDownloadSelector = "a.button.download, a[href*='/downloads/start/'], .download a";
+ public const string FileDownloadSelector = "a.button, a.buttonlarge, a.download, a.btn, a[href*='/downloads/start/'], a[href*='/addons/start/'], a[href*='/downloads/'], a[href*='/addons/'], .download a, .actions a";
/// Selector for file MD5 hash.
public const string FileMd5Selector = ".md5, .hash";
@@ -130,22 +139,41 @@ public static class ModDBParserConstants
// ===== Videos Section Selectors =====
/// Selector for embedded video iframes.
- public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='vimeo'], iframe[src*='youtu.be']";
+ public const string VideoSelector = "iframe[src*='youtube'], iframe[src*='youtube-nocookie'], iframe[src*='youtu.be'], iframe[src*='vimeo'], iframe[src*='dailymotion'], iframe[src*='moddb.com/media/iframe'], iframe[src*='moddb.com/media/embed'], iframe[src*='moddb.com/videos/iframe'], iframe[src*='moddb.com/videos/embed']";
+
+ /// Selector for video gallery containers and items.
+ public const string VideoGallerySelector = "#videobox, #videosbrowse, #mediabrowse, .mediarow, .mediabox";
+
+ /// Selector for video links.
+ public const string VideoLinkSelector = "a[href*='/videos/'], a[href*='youtube.com/watch'], a[href*='youtu.be/'], a[href*='vimeo.com/']";
/// Selector for video thumbnails.
- public const string VideoThumbnailSelector = ".thumbnail img, .preview img";
+ public const string VideoThumbnailSelector = ".thumbnail img, .preview img, img";
/// Selector for video titles.
- public const string VideoTitleSelector = ".title, h3, h4";
+ public const string VideoTitleSelector = ".title, h3, h4, h5, .caption";
+
+ /// Selector for recommendation and related content sections.
+ public const string RecommendationsSelector = "#recommendations, .recommendations, #related, .related, #similar, .similar, #fansalsoviewed, .fansalsoviewed, .youmayalso, [class*='recommend'], [id*='recommend'], [class*='similar'], [id*='similar']";
// ===== Images Section Selectors =====
/// Selector for image gallery container.
- public const string ImageGallerySelector = ".mediarow, .screenshot, .imagebox, .gallery";
+ public const string ImageGallerySelector = "#imagebox, #mediaimage, #imagebrowse, #mediabrowse, .mediarow";
+
+ ///
+ /// Selector for gallery images only. Deliberately excludes a blanket
+ /// img[src*='media.moddb.com'] match, which previously pulled game icons, member
+ /// avatars, and file-page chrome into the Media tab.
+ ///
+ public const string GalleryImageSelector = "#imagebox img, #mediaimage img, #imagebrowse img, #mediabrowse img, .mediarow img, .media .holder img, #downloadsummary img, #downloaddescription img, .preview img, a[href*='/mods/'][href*='/images/'] img";
/// Selector for individual images.
public const string ImageSelector = "img";
+ /// Sidebar/profile containers whose images are icons and avatars, not gallery media.
+ public const string ImageSidebarSelector = "#modsinfo, #downloadsprofilemenu, #profile, .sidecolumn, aside";
+
/// Selector for image thumbnails.
public const string ImageThumbnailSelector = ".thumbnail img, .thumb img";
@@ -197,20 +225,24 @@ public static class ModDBParserConstants
// ===== Comments Section Selectors =====
- /// Selector for comments container.
- public const string CommentsSelector = ".comment, .post, .comments";
+ /// Selector for comments container. Do not use #commentform — that is the composer.
+ public const string CommentsSelector = "#commentsbrowse";
- /// Selector for individual comment rows.
- public const string CommentRowSelector = ".comment, .post";
+ ///
+ /// Selector for posted comment rows. Requires the exact rowcomment class so the
+ /// composer rows (rowcommentguest, rowcommentsummary, rowcommentemail)
+ /// and #commentform are not treated as comments.
+ ///
+ public const string CommentRowSelector = ".row.rowcomment, .rowcomment";
/// Selector for comment authors.
- public const string CommentAuthorSelector = ".author, .username, a[href*='/members/']";
+ public const string CommentAuthorSelector = ".author, .username, .heading a, a[href*='/members/']";
- /// Selector for comment content.
- public const string CommentContentSelector = ".content, .body, .text";
+ /// Selector for comment content. Avoids bare p which matches login chrome and CSS blobs.
+ public const string CommentContentSelector = ":scope > .commentbody, .commentbody, p.comment";
/// Selector for comment dates.
- public const string CommentDateSelector = "time[datetime], .date";
+ public const string CommentDateSelector = "time[datetime], time, .date, .datetime, span.subheading";
/// Selector for comment karma/votes.
public const string CommentKarmaSelector = ".karma, .votes, .goodkarma, .badkarma";
@@ -245,4 +277,100 @@ public static class ModDBParserConstants
/// Pattern for games URLs.
public const string GamesUrlPattern = "/games/";
+
+ // ===== Mod Detail Page Selectors =====
+
+ /// Selector for the downloads section on mod pages.
+ public const string DownloadsSectionSelector = "#downloads, .downloads, .files";
+
+ /// Selector for the addons section on mod pages.
+ public const string AddonsSectionSelector = "#addons, .addons";
+
+ /// Selector for the tabs/navigation on mod pages.
+ public const string TabsSelector = ".tabs, .navigation, nav";
+
+ /// Selector for individual tab links.
+ public const string TabLinkSelector = "a[href*='/downloads'], a[href*='/addons']";
+
+ // ===== Metadata Keys (Internal/Normalized) =====
+
+ /// Metadata key for filename.
+ public const string MetadataFilename = "filename";
+
+ /// Alternative metadata key for filename.
+ public const string MetadataFileNameAlt = "file name";
+
+ /// Alternative metadata key for file.
+ public const string MetadataFileAlt = "file";
+
+ /// Metadata key for size.
+ public const string MetadataSize = "size";
+
+ /// Alternative metadata key for size.
+ public const string MetadataFileSizeAlt = "file size";
+
+ /// Metadata key for uploader.
+ public const string MetadataUploader = "uploader";
+
+ /// Alternative metadata key for uploaded by.
+ public const string MetadataUploadedBy = "uploaded by";
+
+ /// Alternative metadata key for author.
+ public const string MetadataAuthor = "author";
+
+ /// Metadata key for category.
+ public const string MetadataCategory = "category";
+
+ /// Alternative metadata key for file category.
+ public const string MetadataFileCategory = "file category";
+
+ /// Alternative metadata key for type.
+ public const string MetadataType = "type";
+
+ /// Metadata key for MD5 hash.
+ public const string MetadataMd5Hash = "md5 hash";
+
+ /// Metadata key for MD5 hash (alternative).
+ public const string MetadataMd5HashAlt = "md5hash";
+
+ /// Alternative metadata key for MD5 checksum.
+ public const string MetadataMd5Checksum = "md5 checksum";
+
+ /// Alternative metadata key for MD5.
+ public const string MetadataMd5 = "md5";
+
+ /// Alternative metadata key for hash.
+ public const string MetadataHash = "hash";
+
+ /// Alternative metadata key for checksum.
+ public const string MetadataChecksum = "checksum";
+
+ /// Metadata key for total downloads.
+ public const string MetadataTotalDownloads = "total downloads";
+
+ /// Alternative metadata key for download count.
+ public const string MetadataDownloadCount = "download count";
+
+ /// Metadata key for added date.
+ public const string MetadataAdded = "added";
+
+ /// Metadata key for updated date.
+ public const string MetadataUpdated = "updated";
+
+ // ===== Additional Selectors =====
+
+ /// Selector for fallback titles (h1, h2, etc).
+ public const string FallbackTitleSelector = "h2 a, h1 a, h2, h1";
+
+ /// Selector for file detail page title heading outside the global headerbox.
+ public const string FilePageTitleSelector = ".midcolumn h2, .columncenter h2, #downloadsfiles h2, #downloadsinfo h2, #downloads h2, .heading h2, .title h2, h2.title, .midcolumn h3, .heading h3";
+
+ /// Selector for file detail page preview images.
+ public const string FilePreviewImagesSelector = "#downloadmedia img, #downloadsmedia img, #preview img, #media img, .mediagallery img, .imagebox img, #imagebox img, #downloaddescription img, #downloadsummary img, a[href*='/images/'] img, .previewholder img, .media .holder img";
+
+ /// Selector for file description container elements.
+ public const string FileDescriptionContainerSelector = "#downloaddescription, #downloadsummary, #description, .description, .articlebody, #profiletotal";
+
+ /// Regex pattern for extracting parent mod path.
+ public const string ParentModPathRegex = @"(/mods/[^/]+)/(?:downloads|addons)/";
}
diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
new file mode 100644
index 000000000..b19f11d94
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities,
+/// and normalizing text descriptions for display across the application.
+///
+public static partial class HtmlTextHelper
+{
+ ///
+ /// Converts an HTML snippet or formatted description into clean, normalized plain text:
+ /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks.
+ /// - Strips all remaining HTML tags.
+ /// - Decodes HTML entities (e.g., &, ", >, ).
+ /// - Normalizes whitespace and excessive blank lines.
+ /// - Uses the platform newline format.
+ ///
+ /// The raw HTML or formatted text string to normalize.
+ /// Normalized plain text, or empty string if input is null or whitespace.
+ public static string NormalizeHtml(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return string.Empty;
+ }
+
+ // 0. Remove script and style elements along with their contents
+ var text = ScriptTagRegex().Replace(html, string.Empty);
+ text = StyleTagRegex().Replace(text, string.Empty);
+
+ // 1. Convert
tags to newline
+ text = BrTagRegex().Replace(text, "\n");
+
+ // 2. Convert paragraph closing tags to double newline for paragraph separation
+ text = ParagraphCloseTagRegex().Replace(text, "\n\n");
+
+ // 3. Convert other block-level closing tags and
tags to newline
+ text = BlockCloseTagRegex().Replace(text, "\n");
+
+ // 4. Strip all remaining HTML/XML tags
+ text = HtmlTagRegex().Replace(text, string.Empty);
+
+ // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.)
+ text = WebUtility.HtmlDecode(text);
+
+ // 6. Normalize non-breaking spaces and line endings
+ text = text.Replace('\u00A0', ' ')
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n');
+
+ // 7. Clean trailing whitespace on lines and collapse excess blank lines
+ text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n");
+ text = ExcessBlankLinesRegex().Replace(text, "\n\n");
+
+ // 8. Trim and unify with environment newline
+ text = text.Trim();
+ text = text.Replace("\n", Environment.NewLine);
+
+ return text;
+ }
+
+ ///
+ /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags,
+ /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis.
+ ///
+ /// The input HTML or text string.
+ /// Optional maximum character length including ellipsis.
+ /// A single-line plain text summary.
+ public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null)
+ {
+ if (string.IsNullOrWhiteSpace(htmlOrText))
+ {
+ return string.Empty;
+ }
+
+ // Strip HTML if tags exist, decode entities, and normalize
+ var text = NormalizeHtml(htmlOrText);
+
+ // Collapse all newlines, tabs, and multiple spaces into a single space
+ text = MultiWhitespaceRegex().Replace(text, " ").Trim();
+
+ if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value)
+ {
+ return TruncateWithEllipsis(text, maxLength.Value);
+ }
+
+ return text;
+ }
+
+ ///
+ /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated.
+ ///
+ /// The text to truncate.
+ /// The maximum allowed length (including the ellipsis).
+ /// The truncated text with an ellipsis if it exceeded maxLength, or the original text.
+ public static string TruncateWithEllipsis(string? text, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(text) || maxLength <= 0)
+ {
+ return string.Empty;
+ }
+
+ if (text.Length <= maxLength)
+ {
+ return text;
+ }
+
+ if (maxLength <= 3)
+ {
+ return text[..maxLength];
+ }
+
+ return string.Concat(text.AsSpan(0, maxLength - 3), "...");
+ }
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ScriptTagRegex();
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex StyleTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BrTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ParagraphCloseTagRegex();
+
+ [GeneratedRegex(@"?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BlockCloseTagRegex();
+
+ [GeneratedRegex(@"?[A-Za-z][^>]*>", RegexOptions.CultureInvariant)]
+ private static partial Regex HtmlTagRegex();
+
+ [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingWhitespaceBeforeNewlineRegex();
+
+ [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)]
+ private static partial Regex ExcessBlankLinesRegex();
+
+ [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
+ private static partial Regex MultiWhitespaceRegex();
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
index 136018297..2d75645fe 100644
--- a/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
+++ b/GenHub/GenHub.Core/Interfaces/Parsers/IWebPageParser.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Models.Parsers;
namespace GenHub.Core.Interfaces.Parsers;
@@ -36,4 +41,56 @@ public interface IWebPageParser
/// Cancellation token.
/// A parsed web page with all extracted content sections.
Task ParseAsync(string url, string html, CancellationToken cancellationToken = default);
+
+ ///
+ /// Parses a specific file or item detail page.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URL.
+ /// Cancellation token.
+ /// A parsed web page containing the detailed file information.
+ Task ParseFileDetailAsync(string url, CancellationToken cancellationToken = default)
+ => ParseAsync(url, cancellationToken);
+
+ ///
+ /// Parses multiple file or item detail pages in a batch.
+ /// Default implementation delegates to .
+ ///
+ /// The detail page URLs to parse.
+ /// Cancellation token.
+ /// A dictionary mapping each URL to its parsed web page result.
+ async Task> ParseFileDetailsManyAsync(
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(urls);
+ var results = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls.Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var page = await ParseFileDetailAsync(url, cancellationToken);
+ results[url] = page;
+ }
+ catch (HttpRequestException)
+ {
+ // soft failure per url in batch
+ }
+ catch (IOException)
+ {
+ // soft failure per url in batch
+ }
+ catch (InvalidOperationException)
+ {
+ // soft failure per url in batch
+ }
+ catch (FormatException)
+ {
+ // soft failure per url in batch
+ }
+ }
+
+ return results;
+ }
}
diff --git a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
index f2007576a..df73f9827 100644
--- a/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Tools/IPlaywrightService.cs
@@ -6,6 +6,7 @@
using GenHub.Core.Models.Results;
using Microsoft.Playwright;
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -25,6 +26,31 @@ public interface IPlaywrightService
/// A new IPage instance.
Task CreatePageAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default);
+ ///
+ /// Creates a page in a persistent, headed browser context whose cookies and storage survive
+ /// across calls. Use this for bot-protected sites (e.g. ModDB's Cloudflare): the user solves
+ /// the challenge once, the resulting clearance cookie is persisted to disk, and subsequent
+ /// pages in the same session (and across app restarts, until the cookie expires) load without
+ /// another challenge. A real browser window is shown while the challenge is pending.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// Cancellation token.
+ /// A new in the persistent context.
+ Task CreatePersistentPageAsync(string profileName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Closes a page from and shuts down the headed Chromium
+ /// window when no active pages remain. Prefer this over page.CloseAsync alone so
+ /// callers do not leave an about:blank window open after a successful ModDB scrape.
+ ///
+ /// The persistent-context page to close.
+ ///
+ /// When , leaves the page open (e.g. so the user can finish a Cloudflare
+ /// challenge) without closing the browser.
+ ///
+ /// A task representing the asynchronous operation.
+ Task ClosePersistentPageAsync(IPage page, bool keepOpen = false);
+
///
/// Fetches HTML content from a URL using Playwright.
///
@@ -41,6 +67,35 @@ public interface IPlaywrightService
/// A parsed AngleSharp IDocument.
Task FetchAndParseAsync(string url, CancellationToken cancellationToken = default);
+ ///
+ /// Fetches and parses a web page in a persistent, headed browser context whose cookies survive
+ /// across calls. Use this for bot-protected URLs (e.g. ModDB) so the Cloudflare clearance cookie
+ /// obtained from a single manual challenge solve is reused.
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// The URL to fetch and parse.
+ /// Cancellation token.
+ /// A parsed AngleSharp IDocument.
+ Task FetchAndParsePersistentAsync(string profileName, string url, CancellationToken cancellationToken = default);
+
+ ///
+ /// Fetches and parses multiple URLs in one persistent headed page — open once, navigate each
+ /// URL in order, then close. Use this for ModDB section sweeps so Chromium does not spawn a
+ /// new window per section (and so concurrent NewPage/Close races cannot tear down the context
+ /// mid-navigation).
+ ///
+ /// The on-disk profile name (scoped under the app data browser-profile root).
+ /// URLs to fetch in order. Duplicates are fetched once; order of first occurrence is kept.
+ /// Cancellation token.
+ ///
+ /// A map of URL → parsed document for every URL that loaded successfully. Failed URLs are omitted;
+ /// callers should treat a missing key as a soft failure for that section.
+ ///
+ Task> FetchAndParsePersistentManyAsync(
+ string profileName,
+ IReadOnlyList urls,
+ CancellationToken cancellationToken = default);
+
///
/// Downloads a file using Playwright to handle complex scenarios (like anti-bot protections).
///
@@ -48,4 +103,27 @@ public interface IPlaywrightService
/// Cancellation token.
/// A DownloadResult indicating success or failure.
Task DownloadFileAsync(DownloadConfiguration configuration, CancellationToken cancellationToken = default);
+
+ ///
+ /// Executes an operation within a scoped persistent browser context session.
+ /// The persistent browser window stays open for the duration of the operation and closes
+ /// immediately when the operation completes, avoiding multiple window launches and idle delays.
+ ///
+ /// The return type of the operation.
+ /// The on-disk profile name.
+ /// The asynchronous operation to execute.
+ /// Cancellation token.
+ /// The result of the operation.
+ Task ExecuteInPersistentContextAsync(
+ string profileName,
+ Func> operation,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Asynchronously pre-warms the Playwright driver runtime in the background so subsequent
+ /// browser operations launch with minimal latency.
+ ///
+ /// Cancellation token.
+ /// A task representing the background warmup operation.
+ Task WarmupAsync(CancellationToken cancellationToken = default);
}
diff --git a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
index 347634147..979071455 100644
--- a/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/MapDetails.cs
@@ -37,4 +37,4 @@ public record MapDetails(
string? FileType = null,
float? Rating = null,
string? RefererUrl = null,
- List? AdditionalFiles = null);
+ List? AdditionalFiles = null);
diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
index cf2fc5920..c0f1e9a1f 100644
--- a/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/ModDBCategoryMapper.cs
@@ -19,8 +19,8 @@ public static ContentType MapCategory(string? categoryCode)
// Releases (Mods)
"2" => ContentType.Mod, // Full Version
"3" => ContentType.Mod, // Demo
- "4" => ContentType.Patch, // Patch
- "28" => ContentType.Patch, // Script
+ "4" => ContentType.Mod, // Patch (mod release/update)
+ "28" => ContentType.Mod, // Script (mod script/release)
"29" => ContentType.Addon, // Trainer
// Media
@@ -60,11 +60,11 @@ public static ContentType MapCategory(string? categoryCode)
"131" => ContentType.Addon, // Model Pack
// Addons - Skins
- "112" => ContentType.Skin, // Player Skin
- "133" => ContentType.Skin, // Prop Skin
- "113" => ContentType.Skin, // Vehicle Skin
- "114" => ContentType.Skin, // Weapon Skin
- "134" => ContentType.Skin, // Skin Pack
+ "112" => ContentType.Addon, // Player Skin
+ "133" => ContentType.Addon, // Prop Skin
+ "113" => ContentType.Addon, // Vehicle Skin
+ "114" => ContentType.Addon, // Weapon Skin
+ "134" => ContentType.Addon, // Skin Pack
// Addons - Audio
"117" => ContentType.Addon, // Music
@@ -75,8 +75,8 @@ public static ContentType MapCategory(string? categoryCode)
// Addons - Graphics
"124" => ContentType.Addon, // Decal
"136" => ContentType.Addon, // Effects GFX
- "125" => ContentType.Skin, // GUI
- "126" => ContentType.Skin, // HUD
+ "125" => ContentType.Addon, // GUI
+ "126" => ContentType.Addon, // HUD
"128" => ContentType.Addon, // Sprite
"129" => ContentType.Addon, // Texture
@@ -103,10 +103,15 @@ public static ContentType MapCategoryByName(string? categoryName)
{
var s when s.Contains("full version") => ContentType.Mod,
var s when s.Contains("demo") => ContentType.Mod,
- var s when s.Contains("patch") => ContentType.Patch,
- var s when s.Contains("script") => ContentType.Patch,
+ var s when s.Contains("patch") => ContentType.Mod,
+ var s when s.Contains("script") => ContentType.Mod,
var s when s.Contains("trainer") => ContentType.Addon,
+ var s when s.Contains("tool") => ContentType.ModdingTool,
+ var s when s.Contains("sdk") => ContentType.ModdingTool,
+ var s when s.Contains("ide") => ContentType.ModdingTool,
+ var s when s.Contains("source code") => ContentType.ModdingTool,
+
var s when s.Contains("trailer") => ContentType.Video,
var s when s.Contains("movie") => ContentType.Video,
var s when s.Contains("video") => ContentType.Video,
@@ -116,17 +121,12 @@ var s when s.Contains("singleplayer map") => ContentType.Map,
var s when s.Contains("map") => ContentType.Map,
var s when s.Contains("prefab") => ContentType.Map,
- var s when s.Contains("skin") => ContentType.Skin,
- var s when s.Contains("gui") => ContentType.Skin,
- var s when s.Contains("hud") => ContentType.Skin,
+ var s when s.Contains("skin") => ContentType.Addon,
+ var s when s.Contains("gui") => ContentType.Addon,
+ var s when s.Contains("hud") => ContentType.Addon,
var s when s.Contains("language") => ContentType.LanguagePack,
- var s when s.Contains("tool") => ContentType.ModdingTool,
- var s when s.Contains("sdk") => ContentType.ModdingTool,
- var s when s.Contains("ide") => ContentType.ModdingTool,
- var s when s.Contains("source code") => ContentType.ModdingTool,
-
_ => ContentType.Addon,
};
}
diff --git a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
index a977a5f08..88232974f 100644
--- a/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
+++ b/GenHub/GenHub.Core/Models/ModDB/ModDBFilter.cs
@@ -1,3 +1,7 @@
+using System;
+using System.Collections.Generic;
+using GenHub.Core.Constants;
+
namespace GenHub.Core.Models.ModDB;
///
@@ -21,7 +25,7 @@ public class ModDBFilter
public string? Licence { get; set; }
/// Gets or sets the sort parameter.
- public string? Sort { get; set; }
+ public string? Sort { get; set; } = ModDBConstants.DefaultSort;
/// Gets or sets the page number (1-based).
public int Page { get; set; } = 1;
diff --git a/GenHub/GenHub.Core/Models/Parsers/Comment.cs b/GenHub/GenHub.Core/Models/Parsers/Comment.cs
index 645e1fd5a..dd1532318 100644
--- a/GenHub/GenHub.Core/Models/Parsers/Comment.cs
+++ b/GenHub/GenHub.Core/Models/Parsers/Comment.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+
namespace GenHub.Core.Models.Parsers;
///
@@ -8,9 +11,13 @@ namespace GenHub.Core.Models.Parsers;
/// The comment date (optional).
/// The karma/vote score (optional).
/// Whether the comment is from the content creator (optional).
+/// Indentation depth level for reply threads (optional).
+/// Child replies to this comment (optional).
public record Comment(
string? Author = null,
string? Content = null,
DateTime? Date = null,
int? Karma = null,
- bool? IsCreator = null) : ContentSection(SectionType.Comment, "Comment");
+ bool? IsCreator = null,
+ int IndentLevel = 0,
+ IReadOnlyList? Replies = null) : ContentSection(SectionType.Comment, "Comment");
diff --git a/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs
new file mode 100644
index 000000000..0df14e67a
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Parsers/DownloadableFile.cs
@@ -0,0 +1,42 @@
+namespace GenHub.Core.Models.Parsers;
+
+///
+/// Represents a downloadable file extracted from a web page.
+///
+/// The file name.
+/// The file version (optional).
+/// File size in bytes (optional).
+/// Human-readable file size (optional).
+/// The upload date (optional).
+/// The file category (optional).
+/// The uploader name (optional).
+/// The download URL (optional).
+/// The MD5 hash of the file (optional).
+/// Number of comments (optional).
+/// The thumbnail image URL (optional).
+/// Number of downloads (optional).
+/// The file section type (Downloads or Addons).
+/// The release date (optional, may differ from upload date).
+/// The web page details URL (optional).
+/// The full description or release notes (optional).
+/// List of preview image URLs (optional).
+/// The actual file archive name (optional).
+public record DownloadableFile(
+ string Name,
+ string? Version = null,
+ long? SizeBytes = null,
+ string? SizeDisplay = null,
+ DateTime? UploadDate = null,
+ string? Category = null,
+ string? Uploader = null,
+ string? DownloadUrl = null,
+ string? Md5Hash = null,
+ int? CommentCount = null,
+ string? ThumbnailUrl = null,
+ int? DownloadCount = null,
+ FileSectionType FileSectionType = FileSectionType.Downloads,
+ DateTime? ReleaseDate = null,
+ string? DetailsUrl = null,
+ string? Description = null,
+ System.Collections.Generic.IReadOnlyList? PreviewImages = null,
+ string? Filename = null) : ContentSection(SectionType.File, Name);
diff --git a/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs
new file mode 100644
index 000000000..9fddb1afb
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Parsers/FileSectionType.cs
@@ -0,0 +1,13 @@
+namespace GenHub.Core.Models.Parsers;
+
+///
+/// Represents the type of file section, distinguishing between main releases and addon files.
+///
+public enum FileSectionType
+{
+ /// Files from the main releases/downloads section.
+ Downloads,
+
+ /// Files from the addons section.
+ Addons,
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs
new file mode 100644
index 000000000..73e18587f
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ModDB/ModDBCategoryMapperTests.cs
@@ -0,0 +1,63 @@
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.ModDB;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.ModDB;
+
+///
+/// Unit tests for .
+///
+public class ModDBCategoryMapperTests
+{
+ ///
+ /// Verifies that MapCategory maps ModDB category codes correctly, especially mapping patches and scripts to Mod.
+ ///
+ /// The category code to map.
+ /// The expected content type.
+ [Theory]
+ [InlineData("2", ContentType.Mod)]
+ [InlineData("3", ContentType.Mod)]
+ [InlineData("4", ContentType.Mod)]
+ [InlineData("28", ContentType.Mod)]
+ [InlineData("29", ContentType.Addon)]
+ [InlineData("7", ContentType.Video)]
+ [InlineData("8", ContentType.Video)]
+ [InlineData("101", ContentType.Map)]
+ [InlineData("102", ContentType.Map)]
+ [InlineData("112", ContentType.Addon)]
+ [InlineData("125", ContentType.Addon)]
+ [InlineData("126", ContentType.Addon)]
+ [InlineData("20", ContentType.ModdingTool)]
+ [InlineData("30", ContentType.LanguagePack)]
+ public void MapCategory_MapsCategoryCodesCorrectly(string categoryCode, ContentType expected)
+ {
+ var result = ModDBCategoryMapper.MapCategory(categoryCode);
+ Assert.Equal(expected, result);
+ }
+
+ ///
+ /// Verifies that MapCategoryByName maps category names correctly, mapping patch and script names to Mod.
+ ///
+ /// The category name to map.
+ /// The expected content type.
+ [Theory]
+ [InlineData("Full Version", ContentType.Mod)]
+ [InlineData("Demo", ContentType.Mod)]
+ [InlineData("Patch", ContentType.Mod)]
+ [InlineData("v1.01 Patch", ContentType.Mod)]
+ [InlineData("Script", ContentType.Mod)]
+ [InlineData("Multiplayer Map", ContentType.Map)]
+ [InlineData("Singleplayer Map", ContentType.Map)]
+ [InlineData("Player Skin", ContentType.Addon)]
+ [InlineData("GUI", ContentType.Addon)]
+ [InlineData("HUD", ContentType.Addon)]
+ [InlineData("Mapping Tool", ContentType.ModdingTool)]
+ [InlineData("Language Pack", ContentType.LanguagePack)]
+ [InlineData("Trailer", ContentType.Video)]
+ public void MapCategoryByName_MapsNamesCorrectly(string categoryName, ContentType expected)
+ {
+ var result = ModDBCategoryMapper.MapCategoryByName(categoryName);
+ Assert.Equal(expected, result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs
new file mode 100644
index 000000000..530c2e224
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Parsers/ModDBPageParserTests.cs
@@ -0,0 +1,1664 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AngleSharp;
+using AngleSharp.Dom;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Tools;
+using GenHub.Core.Models.Parsers;
+using GenHub.Features.Content.Services.Parsers;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.Content.Parsers;
+
+///
+/// Regression tests for the current ModDB detail markup and Cloudflare-aware section loading.
+///
+public sealed class ModDBPageParserTests
+{
+ ///
+ /// Verifies the current game-addon detail page maps its metadata and /addons/start route into
+ /// a usable file rather than returning an empty download URL.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_CurrentAddonDetailMarkup_ExtractsArchiveNameAndAddonStartUrlAsync()
+ {
+ // Arrange
+ var playwright = CreatePlaywrightMock();
+ var pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons/lemuria-2026-fixes";
+ var doc = await CreateDocumentAsync("""
+
+
+
Filename
Lemuria_2026_Fixes.rar
+
+
+
Added
+
Size
1.07mb (1,125,450 bytes)
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Lemuria_2026_Fixes.rar", file.Name);
+ Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl);
+ Assert.Equal("Singleplayer Map", file.Category);
+ Assert.Equal(1_125_450, file.SizeBytes);
+ }
+
+ ///
+ /// Game-scoped FileDetail URLs (from the ModDB downloads listing) have no parent /mods/ page
+ /// to sweep, so the detail view must still populate Community from comments on the file page
+ /// itself instead of leaving only a single Releases row.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_GameFileDetail_ExtractsOnPageCommentsWithoutParentSweepAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/downloads/genbigeditbig-editor";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
Filename
GenBigEdit.zip
+
Size
174.33mb (182,801,143 bytes)
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ Assert.Equal(PageType.FileDetail, parsed.PageType);
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GenBigEdit.zip", file.Name);
+ Assert.Equal("https://www.moddb.com/downloads/start/310120", file.DownloadUrl);
+
+ var comment = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("mah_boi", comment.Author);
+ Assert.Equal("Please, provide us the source code of this program.", comment.Content);
+
+ // Must not attempt a parent-mod section sweep for /games/... FileDetail URLs (fetches only the single URL).
+ playwright.Verify(
+ service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is>(urls => urls.Count == 1 && urls[0] == pageUrl),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies an addons-list row retains its ModDB category so a map does not become a generic
+ /// add-on later in the resolver and manifest pipeline.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_AddonsListRow_ExtractsSingleplayerMapCategoryAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/games/cc-generals-zero-hour/addons";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
Lemuria 2026
+
Singleplayer Map
+
1.07 MB
+
Download
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Singleplayer Map", file.Category);
+ Assert.Equal(FileSectionType.Addons, file.FileSectionType);
+ Assert.Equal("https://www.moddb.com/addons/start/302328", file.DownloadUrl);
+ }
+
+ ///
+ /// Verifies that rich ModDB sections use the verified persistent Chromium profile instead of
+ /// a separate headless browser that loses Cloudflare clearance.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ModDetail_UsesPersistentProfileForDownloadsAndAddonsAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("Example Mod
"),
+ [pageUrl + "/downloads"] = await CreateDocumentAsync("""
+
+ """),
+ [pageUrl + "/addons"] = await CreateDocumentAsync("""
+
+ """),
+ [pageUrl + "/videos"] = await CreateDocumentAsync(""),
+ [pageUrl + "/images"] = await CreateDocumentAsync(""),
+ [pageUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [pageUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var d))
+ {
+ result[url] = d;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Contains(files, file => file.Name == "Example Release" && file.DownloadUrl == "https://www.moddb.com/downloads/start/100");
+ Assert.Contains(files, file => file.Name == "Example Addon" && file.DownloadUrl == "https://www.moddb.com/addons/start/200");
+ playwright.Verify(
+ service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is>(urls =>
+ urls.Contains(pageUrl) && urls.Contains(pageUrl + "/downloads") && urls.Contains(pageUrl + "/addons")),
+ It.IsAny()),
+ Times.Once);
+ playwright.Verify(service => service.FetchAndParseAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies the file-only acquisition path resolves a FileDetail download without fetching the
+ /// parent mod's downloads/addons/videos/images/reviews/articles sections (the seven-page sweep
+ /// that previously fired on every card download).
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_FetchesOnlyFileDetailPageAndSkipsSectionSweepAsync()
+ {
+ // Arrange: the FileDetail page already carries a real (non-guest) icon, so the parent-mod
+ // icon fallback fetch is skipped too — exactly one fetch total.
+ const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25";
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ pageUrl,
+ It.IsAny()))
+ .ReturnsAsync(await CreateDocumentAsync("""
+
+
+
+
+
Filename
GenSpeed-v2.5.zip
+
Size
65.04mb (68,197,650 bytes)
+
+
+
+ """));
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ // Assert: exactly one DownloadableFile, no section sweep, icon from the FileDetail page.
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GenSpeed v2.5", file.Name);
+ Assert.Equal("GenSpeed-v2.5.zip", file.Filename);
+ Assert.Equal("https://www.moddb.com/downloads/start/311183", file.DownloadUrl);
+ Assert.Equal(68_197_650, file.SizeBytes);
+ Assert.Equal("https://static.moddb.com/mods/genspeed/icon.png", parsed.Context.IconUrl);
+
+ playwright.Verify(
+ service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ It.Is(url => url != pageUrl),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that ParseFileDetailAsync performs only a single page fetch for file details without
+ /// secondary parent mod fetches or section sweeps.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_WithGuestIcon_FetchesOnlyFileDetailPageAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/genspeed/downloads/genspeed-v25";
+ var fetchedUrls = new List();
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((string _, string url, CancellationToken _) =>
+ {
+ fetchedUrls.Add(url);
+ return Task.FromResult(CreateDocumentAsync("""
+
+
+
+
+
Filename
GenSpeed-v2.5.zip
+
+
+
+ """).GetAwaiter().GetResult());
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ // Assert: exactly one fetch (FileDetail), never parent mod or section pages.
+ Assert.Equal(new[] { pageUrl }, fetchedUrls);
+ Assert.Contains(parsed.Sections.OfType(), f => f.Filename == "GenSpeed-v2.5.zip");
+ }
+
+ ///
+ /// Verifies that comment parsing creates nested reply threads with correct author attribution
+ /// and cleans out ModDB action text like 'Reply Good karma Bad karma+1 vote'.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_NestedComments_ParsesThreadHierarchyAndCleansActionTextAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/comments";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var topLevelComments = parsed.Sections.OfType().ToList();
+ var parentComment = Assert.Single(topLevelComments);
+ Assert.Equal("Scorpionwins", parentComment.Author);
+ Assert.Equal("How to activate additional weapons?", parentComment.Content);
+ Assert.Equal(0, parentComment.IndentLevel);
+
+ var reply = Assert.Single(parentComment.Replies!);
+ Assert.Equal("BagaturKhan", reply.Author);
+ Assert.Equal("If you are talking about stolen tech, train your infiltrator.", reply.Content);
+ Assert.Equal(1, reply.IndentLevel);
+ }
+
+ ///
+ /// Verifies reply markup nested inside .commentbody does not inflate the parent content
+ /// into a huge whitespace block (the layout bug seen in the Community tab).
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_NestedCommentsInsideCommentBody_DoesNotPolluteParentContentAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/comments";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var parentComment = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("How to activate additional weapons?", parentComment.Content);
+ Assert.DoesNotContain("BagaturKhan", parentComment.Content);
+ Assert.DoesNotContain("infiltrator", parentComment.Content, StringComparison.OrdinalIgnoreCase);
+
+ var reply = Assert.Single(parentComment.Replies!);
+ Assert.Equal("BagaturKhan", reply.Author);
+ Assert.Equal("Train your infiltrator.", reply.Content);
+ }
+
+ ///
+ /// Verifies rating widgets without author/body are not surfaced as empty Community review cards.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_BareRatingWidget_IsNotTreatedAsReviewAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/example-mod/reviews";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ 9.0people found this helpful
+
+
Alice
+
Solid patch for ROTR.
+
8.5
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var review = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("Alice", review.Author);
+ Assert.Equal("Solid patch for ROTR.", review.Content);
+ }
+
+ ///
+ /// The live ModDB composer (#commentform plus guest/email rows and injected CSS) must
+ /// not appear as Community comments.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_CommentComposer_IsNotTreatedAsCommentsAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ Assert.Empty(parsed.Sections.OfType());
+ }
+
+ ///
+ /// File-page chrome (game icon, developer avatar, download title art) must not appear in Media.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_FileDetailChromeImages_AreNotGalleryMediaAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+ C&C Generals Undone
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ Assert.Empty(parsed.Sections.OfType());
+ }
+
+ ///
+ /// The images tab should yield unique gallery shots, not share icons or duplicate featured thumbs.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ImagesPage_ExtractsUniqueGalleryShotsAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/images";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var images = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, images.Count);
+ Assert.Contains(images, image => image.Title.Contains("ICBM", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(images, image => image.Title.Contains("Spectre", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(images, image => image.Title.Contains("Share", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(images, image => image.ThumbnailUrl?.StartsWith("data:", StringComparison.OrdinalIgnoreCase) == true);
+ Assert.All(images, image => Assert.DoesNotContain("crop_", image.ThumbnailUrl ?? string.Empty));
+ Assert.All(images, image => Assert.DoesNotContain("/cache/", image.ThumbnailUrl ?? string.Empty));
+ }
+
+ ///
+ /// Image titles with CamelCase or raw filenames should be formatted with clean spaces.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ImageTitles_FormatsCamelCaseAndFilenamesAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/test-mod/images";
+ var playwright = CreatePlaywrightMock();
+ var doc = await CreateDocumentAsync("""
+
+
+
+ """);
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var images = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, images.Count);
+ Assert.Equal("Life Of BRRRRTTT", images[0].Title);
+ Assert.Equal("BASSBASSBASSASS", images[1].Title);
+ Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].ThumbnailUrl);
+ Assert.Equal("https://media.moddb.com/images/mods/1/73/72174/LifeOfBRRRRTTT.png", images[0].FullSizeUrl);
+ }
+
+ ///
+ /// FileDetail filename plus the parent downloads listing of the same start URL must collapse
+ /// to one release, keeping the human listing name.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_FileDetailAndParentDownloads_DedupesSameBinaryAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+ register
+
+ Games : C&C: Generals Zero Hour : Mods : C&C Generals Undone : Files
+ This is the first version of Undone, and I know it's still very much in development.
+
+
Filename
GeneralsUndone_v1.0.zip
+
+
+
+ """),
+ [parentUrl] = await CreateDocumentAsync("C&C Generals Undone
"),
+ [parentUrl + "/downloads"] = await CreateDocumentAsync("""
+
+
C&C Generals Undone
+
289.6 MB
+
Download
+
+
+
Generals Undone v1.01 Patch
+
1 MB
+
Download
+
+ """),
+ [parentUrl + "/addons"] = await CreateDocumentAsync(""),
+ [parentUrl + "/videos"] = await CreateDocumentAsync(""),
+ [parentUrl + "/images"] = await CreateDocumentAsync(""),
+ [parentUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [parentUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var doc))
+ {
+ result[url] = doc;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, files.Count);
+ Assert.Contains(files, file => file.Name == "C&C Generals Undone" && file.DownloadUrl == "https://www.moddb.com/downloads/start/313719");
+ Assert.Contains(files, file => file.Name == "Generals Undone v1.01 Patch");
+ Assert.DoesNotContain(files, file => file.Name == "GeneralsUndone_v1.0.zip");
+ Assert.Equal("C&C Generals Undone", parsed.Context.Title);
+ Assert.Equal("WhiteSkull#9044", parsed.Context.Developer);
+ Assert.Contains("first version of Undone", parsed.Context.Description, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("Games :", parsed.Context.Description, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Verifies that ParseFileDetailAsync correctly parses metadata when ModDB uses alternative label names
+ /// such as "File Name", "File Size", "Uploaded By", "MD5 Checksum", and "Total Downloads".
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseFileDetailAsync_WithAlternativeLabels_ParsesMd5ChecksumTotalDownloadsAndUploaderAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch";
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentAsync(
+ ModDBConstants.BrowserProfileName,
+ pageUrl,
+ It.IsAny()))
+ .ReturnsAsync(await CreateDocumentAsync("""
+
+
+
File Name
GeneralsUndone_v1.01.csf
+
Category
Patch
+
Uploaded By
WhiteSkull#9044
+
File Size
289.6mb (303,663,235 bytes)
+
MD5 Checksum
6e5b1fd58fc7a58cf21af86933116942
+
Total Downloads
185
+
+
+
+ """));
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseFileDetailAsync(pageUrl);
+
+ var file = Assert.Single(parsed.Sections.OfType());
+ Assert.Equal("GeneralsUndone_v1.01.csf", file.Filename);
+ Assert.Equal("Patch", file.Category);
+ Assert.Equal("WhiteSkull#9044", file.Uploader);
+ Assert.Equal(303_663_235, file.SizeBytes);
+ Assert.Equal("6e5b1fd58fc7a58cf21af86933116942", file.Md5Hash);
+ Assert.Equal(185, file.DownloadCount);
+ Assert.Equal("https://www.moddb.com/downloads/start/313720", file.DownloadUrl);
+ }
+
+ ///
+ /// Verifies that ModDB download listing rows with subheading metadata (size in subheading, button class)
+ /// extract size, category, uploader, details URL, and download URL correctly.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_ModernModDBDownloadsListing_ExtractsSubheadingSizeAndLinksAsync()
+ {
+ const string pageUrl = "https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone";
+ const string parentUrl = "https://www.moddb.com/mods/cc-generals-undone";
+ var documents = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [pageUrl] = await CreateDocumentAsync("""
+
+ C&C Generals Undone file
+ C&C Generals Undone
+
+ First release of Undone.
+
+
Filename
GeneralsUndone_v1.0.zip
+
Category
Full Version
+
Size
289.6mb (303,663,235 bytes)
+
MD5 Hash
6e5b3fcf30fc7a58ef21af869551bb942
+
+
+
+ """),
+ [parentUrl] = await CreateDocumentAsync("C&C Generals Undone
"),
+ [parentUrl + "/downloads"] = await CreateDocumentAsync("""
+
+
+
+
- Full Version, 289.6mb
+
+
+
+
+
+
+
- Patch, 1 MB
+
+
+
+ """),
+ [parentUrl + "/addons"] = await CreateDocumentAsync(""),
+ [parentUrl + "/videos"] = await CreateDocumentAsync(""),
+ [parentUrl + "/images"] = await CreateDocumentAsync(""),
+ [parentUrl + "/reviews"] = await CreateDocumentAsync(""),
+ [parentUrl + "/articles"] = await CreateDocumentAsync(""),
+ };
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((string _, IReadOnlyList urls, CancellationToken _) =>
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var url in urls)
+ {
+ if (documents.TryGetValue(url, out var doc))
+ {
+ result[url] = doc;
+ }
+ }
+
+ return Task.FromResult>(result);
+ });
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ var files = parsed.Sections.OfType().ToList();
+ Assert.Equal(2, files.Count);
+
+ var mainRelease = Assert.Single(files, f => f.Name == "C&C Generals Undone");
+ Assert.Equal("GeneralsUndone_v1.0.zip", mainRelease.Filename);
+ Assert.Equal("https://www.moddb.com/downloads/start/313719", mainRelease.DownloadUrl);
+ Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/cc-generals-undone", mainRelease.DetailsUrl);
+ Assert.Equal("Full Version", mainRelease.Category);
+ Assert.Equal(303_663_235, mainRelease.SizeBytes);
+ Assert.Equal("6e5b3fcf30fc7a58ef21af869551bb942", mainRelease.Md5Hash);
+
+ var patchRelease = Assert.Single(files, f => f.Name == "Generals Undone v1.01 Patch");
+ Assert.Equal("https://www.moddb.com/mods/cc-generals-undone/downloads/generals-undone-v101-patch", patchRelease.DetailsUrl);
+ Assert.Equal("Patch", patchRelease.Category);
+ Assert.Equal(1048576, patchRelease.SizeBytes);
+ Assert.Equal("1 MB", patchRelease.SizeDisplay);
+ }
+
+ ///
+ /// Verifies that embedded YouTube iframes on mod pages have their title, thumbnail, platform,
+ /// and normalized embed URL properly extracted.
+ ///
+ /// A task that represents the asynchronous test.
+ [Fact]
+ public async Task ParseAsync_WithYouTubeIframe_ExtractsTitlePlatformThumbnailAndEmbedUrlAsync()
+ {
+ // Arrange
+ const string pageUrl = "https://www.moddb.com/mods/korean-war-2";
+ var doc = await CreateDocumentAsync("""
+
+
+
+
+
+
+
Gameplay Teaser
+
+
+
+ """);
+ var playwright = CreatePlaywrightMock();
+ playwright
+ .Setup(service => service.FetchAndParsePersistentManyAsync(
+ ModDBConstants.BrowserProfileName,
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new Dictionary(StringComparer.OrdinalIgnoreCase) { [pageUrl] = doc });
+
+ var parser = new ModDBPageParser(playwright.Object, new Mock>().Object);
+
+ // Act
+ var parsed = await parser.ParseAsync(pageUrl);
+
+ // Assert
+ var videos = parsed.Sections.OfType