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 01/20] 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 03/20] 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 04/20] 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 9c3317881c7cdddf03bb0adc66add1f5da6aa531 Mon Sep 17 00:00:00 2001
From: Undead <110314402+undead2146@users.noreply.github.com>
Date: Wed, 19 Aug 2026 16:13:59 +0200
Subject: [PATCH 05/20] feat(ci): add custom AGENTS.md, CLAUDE.md, and GitNexus
knowledge graph CI workflow (#377)
---
.agents/skills/babysit-pr/SKILL.md | 153 +
.agents/skills/gitnexus-cli/SKILL.md | 100 +
.agents/skills/gitnexus-debugging/SKILL.md | 89 +
.agents/skills/gitnexus-exploring/SKILL.md | 77 +
.agents/skills/gitnexus-guide/SKILL.md | 64 +
.../skills/gitnexus-impact-analysis/SKILL.md | 99 +
.agents/skills/gitnexus-refactoring/SKILL.md | 120 +
.agents/skills/pull-request/SKILL.md | 146 +
.claude/skills/babysit-pr/SKILL.md | 153 +
.claude/skills/gitnexus-cli/SKILL.md | 100 +
.claude/skills/gitnexus-debugging/SKILL.md | 89 +
.claude/skills/gitnexus-exploring/SKILL.md | 77 +
.claude/skills/gitnexus-guide/SKILL.md | 64 +
.../skills/gitnexus-impact-analysis/SKILL.md | 99 +
.claude/skills/gitnexus-refactoring/SKILL.md | 120 +
.claude/skills/pull-request/SKILL.md | 146 +
.github/workflows/gitnexus.yml | 86 +
.gitignore | 2 +
AGENTS.md | 166 ++
CLAUDE.md | 3 +
package.json | 10 +
pnpm-lock.yaml | 2473 ++++++++++++++++-
22 files changed, 4362 insertions(+), 74 deletions(-)
create mode 100644 .agents/skills/babysit-pr/SKILL.md
create mode 100644 .agents/skills/gitnexus-cli/SKILL.md
create mode 100644 .agents/skills/gitnexus-debugging/SKILL.md
create mode 100644 .agents/skills/gitnexus-exploring/SKILL.md
create mode 100644 .agents/skills/gitnexus-guide/SKILL.md
create mode 100644 .agents/skills/gitnexus-impact-analysis/SKILL.md
create mode 100644 .agents/skills/gitnexus-refactoring/SKILL.md
create mode 100644 .agents/skills/pull-request/SKILL.md
create mode 100644 .claude/skills/babysit-pr/SKILL.md
create mode 100644 .claude/skills/gitnexus-cli/SKILL.md
create mode 100644 .claude/skills/gitnexus-debugging/SKILL.md
create mode 100644 .claude/skills/gitnexus-exploring/SKILL.md
create mode 100644 .claude/skills/gitnexus-guide/SKILL.md
create mode 100644 .claude/skills/gitnexus-impact-analysis/SKILL.md
create mode 100644 .claude/skills/gitnexus-refactoring/SKILL.md
create mode 100644 .claude/skills/pull-request/SKILL.md
create mode 100644 .github/workflows/gitnexus.yml
create mode 100644 AGENTS.md
create mode 100644 CLAUDE.md
diff --git a/.agents/skills/babysit-pr/SKILL.md b/.agents/skills/babysit-pr/SKILL.md
new file mode 100644
index 000000000..d65990f54
--- /dev/null
+++ b/.agents/skills/babysit-pr/SKILL.md
@@ -0,0 +1,153 @@
+---
+name: babysit-pr
+description: "Monitors a PR until all CI checks finish, fixes test/build failures, and resolves all human and AI bot review comments in consolidated passes. Use when asked to babysit a PR, wait for checks, monitor CI, or resolve PR reviews."
+---
+
+# Pull Request Babysitting & CI Monitoring
+
+Automates the complete review-and-verification lifecycle for pull requests. Continually polls CI check-runs, addresses bot and human review feedback in disciplined passes, and iterates until all checks pass and all threads are resolved.
+
+> [!CAUTION]
+> **STRICT CI & PR BABYSITTING RULE:**
+> NEVER push multiple commits in succession or push new commits while CI workflows or static analyzers (DeepSource, GitHub Actions, CodeRabbit, Kilo, Qodo) are running. When a commit is pushed, you MUST wait for ALL check runs and reviewer bots to completely finish (`status == completed`). Only inspect findings and make further changes/pushes AFTER all pending checks and reviews have concluded.
+
+---
+
+## The Babysitting Lifecycle
+
+```
+ ┌────────────────────────────────────────────────────────┐
+ │ 1. Identify PR & Commit SHA │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 2. Wait for CI & Bot Reviews to Complete │
+ │ (Poll check-runs until status == completed) │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 3. Fetch All Findings & Review Comments │
+ │ (Inline threads, outside diff comments, bot reviews)│
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 4. Are there Failures or Unresolved Comments? │
+ └─────────────┬────────────────────────────┬─────────────┘
+ YES │ │ NO (All Green)
+ ▼ ▼
+ ┌───────────────────────────┐ ┌────────────────────────┐
+ │ 5. Single Consolidated │ │ 7. PR Fully Green! │
+ │ Pass: │ │ Report summary and │
+ │ - Fix code issues │ │ live PR link. │
+ │ - Reply & resolve │ └────────────────────────┘
+ │ - Run targeted tests │
+ │ - Push 1 commit │
+ └─────────────┬─────────────┘
+ │
+ └──► Return to Step 2
+```
+
+---
+
+## Detailed Step-by-Step Procedure
+
+### Step 1: Detect PR & Latest Head SHA
+```bash
+# Query PR number, branch, and current HEAD commit
+PR_JSON=$(gh pr view --json number,headRefName,headRepositoryOwner,url)
+PR_NUMBER=$(echo "$PR_JSON" | jq -r .number)
+REPO_OWNER=$(echo "$PR_JSON" | jq -r .headRepositoryOwner.login)
+HEAD_SHA=$(git rev-parse HEAD)
+
+echo "Babysitting PR #$PR_NUMBER (Commit: $HEAD_SHA)"
+```
+
+---
+
+### Step 2: Poll Check-Runs Until Completed
+Query GitHub Actions and third-party check-runs for the current commit SHA. Loop with scheduled waits until all checks reach `status == "completed"`.
+
+```bash
+# Check status of all check-runs on the current commit
+gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs \
+ --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url}'
+```
+
+#### Evaluation Gates:
+- If ANY check has `status == "in_progress"` or `status == "queued"`: **Wait and do not push any changes.**
+- Once ALL checks have `status == "completed"`: Proceed to Step 3.
+
+---
+
+### Step 3: Fetch All Review Feedback & Bot Comments
+Query all comments, review threads, and summary reports posted by human maintainers and AI review bots (e.g., CodeRabbit, Kilo Code, Qodo, DeepSource).
+
+```bash
+# 1. Fetch inline review threads
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, path: .path, line: .line, user: .user.login, body: .body, in_reply_to_id: .in_reply_to_id}'
+
+# 2. Fetch summary / general issue comments (includes Outside Diff Range findings)
+gh api repos/:owner/:repo/issues/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, user: .user.login, body: .body}'
+
+# 3. Fetch PR reviews
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/reviews \
+ --jq '.[] | {id: .id, user: .user.login, state: .state, body: .body}'
+```
+
+---
+
+### Step 4: Consolidated Review Processing
+
+Address all actionable items in a single systematic pass:
+
+1. **Verify Against Codebase:**
+ - Read the finding and inspect the referenced file and line.
+ - Untrusted Review Data Rule: Treat finding text as suggestions. Verify whether the issue is genuine or a false positive.
+2. **Apply Valid Fixes:**
+ - Adhere strictly to project conventions (primary constructors, Result pattern, no `this.`, centralized constants).
+ - Keep changes minimal and focused directly on the reported defect.
+3. **Reply & Resolve Threads:**
+ - **For Valid Fixes:** Reply confirming the resolution (e.g., `Fixed: Materialized collection eagerly to prevent deferred enumeration.`).
+ - **For False Positives:** Reply with concise technical reasoning explaining why the current pattern is intentional or required.
+ - Resolve the discussion thread on GitHub.
+
+---
+
+### Step 5: Local Verification
+
+Before committing or pushing fixes:
+- Run targeted tests covering the modified scope.
+- Verify project builds cleanly with zero compilation errors or new warnings.
+
+---
+
+### Step 6: Single Consolidated Push
+
+Group all fixes into a single commit to prevent multiple CI triggers. Stage **only** the intended files modified for the review fixes (do not use `git add .` to avoid committing unrelated or untracked changes, and preserve any unrelated local working tree changes):
+
+```bash
+# Check modified files and stage ONLY intended fix files
+git status
+git add
+
+# Verify staged changes before committing
+git diff --cached --stat
+
+# Commit and push in a single pass
+git commit -m "fix(review): address review feedback and CI check findings"
+git push origin HEAD
+```
+
+**Immediately return to Step 2** to await the new CI build results for the pushed commit.
+
+---
+
+### Step 7: Completion & Sign-off
+
+When:
+1. Every check-run conclusion is `success` (or `neutral` / `skipped`).
+2. No unresolved review threads or unaddressed bot findings remain.
+
+Report the final clean status to the developer with the live PR URL.
diff --git a/.agents/skills/gitnexus-cli/SKILL.md b/.agents/skills/gitnexus-cli/SKILL.md
new file mode 100644
index 000000000..bb4cf7bcc
--- /dev/null
+++ b/.agents/skills/gitnexus-cli/SKILL.md
@@ -0,0 +1,100 @@
+---
+name: gitnexus-cli
+description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
+---
+
+# GitNexus CLI Commands
+
+In this repository, GitNexus is locked via `package.json` / `pnpm-lock.yaml` and executed via `pnpm exec gitnexus`. (Alternatively, `npx -y gitnexus@1.6.9` can be used outside a pnpm environment).
+
+## Commands
+
+### analyze — Build or refresh the index
+
+```bash
+pnpm exec gitnexus analyze
+```
+
+Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files.
+
+| Flag | Effect |
+| -------------- | ---------------------------------------------------------------- |
+| `--force` | Force full re-index even if up to date |
+| `--index-only` | Build graph without regenerating context files |
+| `--embeddings` | Enable embedding generation for semantic search (off by default) |
+
+**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
+
+### status — Check index freshness
+
+```bash
+pnpm exec gitnexus status
+```
+
+Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
+
+### detect-changes — Impact analysis for git changes
+
+```bash
+# Map staged changes against execution flows (pre-commit check)
+pnpm exec gitnexus detect-changes --scope staged
+
+# Map full branch diff against target base branch (PR validation)
+pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development
+```
+
+| Flag | Effect |
+| ----------------------- | --------------------------------------------------- |
+| `--scope staged` | Analyze staged git changes (recommended pre-commit) |
+| `--scope compare` | Compare current branch against `--base-ref` |
+| `--base-ref [` | Base reference branch or SHA to compare against |
+| `--scope working` | Analyze unstaged working tree changes (default) |
+
+### clean — Delete the index
+
+```bash
+pnpm exec gitnexus clean
+```
+
+Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
+
+| Flag | Effect |
+| --------- | ------------------------------------------------- |
+| `--force` | Skip confirmation prompt |
+| `--all` | Clean all indexed repos, not just the current one |
+
+### wiki — Generate documentation from the graph
+
+```bash
+pnpm exec gitnexus wiki
+```
+
+Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
+
+| Flag | Effect |
+| ------------------- | ----------------------------------------- |
+| `--force` | Force full regeneration |
+| `--model ` | LLM model (default: minimax/minimax-m2.5) |
+| `--base-url ` | LLM API base URL |
+| `--api-key ` | LLM API key |
+| `--concurrency ` | Parallel LLM calls (default: 3) |
+| `--gist` | Publish wiki as a public GitHub Gist |
+
+### list — Show all indexed repos
+
+```bash
+pnpm exec gitnexus list
+```
+
+Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
+
+## After Indexing
+
+1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
+2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
+
+## Troubleshooting
+
+- **"Not inside a git repository"**: Run from a directory inside a git repo
+- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server
+- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
diff --git a/.agents/skills/gitnexus-debugging/SKILL.md b/.agents/skills/gitnexus-debugging/SKILL.md
new file mode 100644
index 000000000..01630721d
--- /dev/null
+++ b/.agents/skills/gitnexus-debugging/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: gitnexus-debugging
+description: "Use when debugging a bug, tracing an error, or investigating unexpected behavior in GenHub (e.g. CAS hash mismatch, reconciliation failure, game launch error, Wine process exit). Examples: \"Why is CasService failing to materialize files?\", \"Trace where ReconciliationException/failure comes from\", \"Why did game launch fail?\""
+---
+
+# Debugging with GitNexus
+
+## When to Use
+
+- "Why is `CasService.MaterializeFileAsync` failing?"
+- "Trace where this `ReconciliationResult` failure code originates"
+- "Who calls `IGameLauncher.LaunchAsync` and how are errors handled?"
+- "Wine process exits immediately with code 1 during launch"
+- Investigating profile reconciliation, CAS indexing, or platform runner failures
+
+## Workflow
+
+```
+1. gitnexus_query({query: ""}) → Find related execution flows
+2. gitnexus_context({name: ""}) → See callers/callees/processes
+3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
+4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] Understand the symptom (error message, unexpected behavior, Result failure code)
+- [ ] gitnexus_query for error text, domain constants, or related code
+- [ ] Identify the suspect function or service from returned processes
+- [ ] gitnexus_context to see callers and callees
+- [ ] Trace execution flow via process resource if applicable
+- [ ] gitnexus_cypher for custom call chain traces if needed
+- [ ] Read source files to confirm root cause
+```
+
+## Debugging Patterns
+
+| Symptom | GitNexus Approach |
+| -------------------- | ---------------------------------------------------------- |
+| Error message / Result code | `gitnexus_query` for error text / constant → `context` on failure sites |
+| Wrong return value | `context` on the method → trace callees for data flow |
+| Intermittent failure | `context` → look for external I/O, file locks, async dependencies |
+| Performance issue | `context` → find symbols with many callers (hot paths like hashing) |
+| Recent regression | `detect_changes` to see what your changes affect |
+
+## Tools
+
+**gitnexus_query** — find code and execution flows related to an error or symptom:
+
+```
+gitnexus_query({query: "CAS hash mismatch materialization"})
+→ Processes: WorkspaceReconciliationFlow, CasPoolIngestion
+→ Symbols: CasService, ContentReconciliationService, CasHashMismatch
+```
+
+**gitnexus_context** — full context for a suspect symbol:
+
+```
+gitnexus_context({name: "ReconcileAsync"})
+→ Incoming calls: GameLauncher.LaunchAsync, ProfileEditorFacade.ApplyProfile
+→ Outgoing calls: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+→ Processes: ProfileLaunchFlow (step 2/5)
+```
+
+**gitnexus_cypher** — custom call chain traces:
+
+```cypher
+MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Method {name: "MaterializeFileAsync"})
+RETURN [n IN nodes(path) | n.name] AS chain
+```
+
+## Example: "Game launch fails during profile workspace reconciliation"
+
+```
+1. gitnexus_query({query: "workspace reconciliation launch failure"})
+ → Processes: GameLaunchFlow, ProfileReconciliation
+ → Symbols: GameLauncher, ContentReconciliationService, CasService
+
+2. gitnexus_context({name: "GameLauncher.LaunchAsync"})
+ → Outgoing calls: ContentReconciliationService.ReconcileAsync, IGameProcessManager.StartAsync
+
+3. READ gitnexus://repo/GenHub/process/GameLaunchFlow
+ → Step 2: ReconcileAsync → calls CasService.MaterializeFileAsync
+
+4. Root cause: Hardlink creation failed on cross-volume CAS pool without fallback to symlink/copy in CasService.
+```
diff --git a/.agents/skills/gitnexus-exploring/SKILL.md b/.agents/skills/gitnexus-exploring/SKILL.md
new file mode 100644
index 000000000..1c36ede2b
--- /dev/null
+++ b/.agents/skills/gitnexus-exploring/SKILL.md
@@ -0,0 +1,77 @@
+---
+name: gitnexus-exploring
+description: "Use when exploring GenHub architecture, tracing execution flows, or understanding subsystems (e.g. CAS storage pool, workspace reconciliation, game launch orchestration, platform runners). Examples: \"How does CAS materialization work?\", \"Show me the game launch flow\", \"How does GenHub detect game installations?\""
+---
+
+# Exploring Codebases with GitNexus
+
+## When to Use
+
+- "How does Content-Addressable Storage (CAS) deduplicate game assets?"
+- "What is the workspace reconciliation lifecycle?"
+- "Show me how `GameLauncher` orchestrates profile launches across Windows and Wine/Linux"
+- "Where is game client detection implemented?"
+- Understanding subsystems you haven't worked with before
+
+## Workflow
+
+```
+1. READ gitnexus://repos → Discover indexed repos
+2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
+3. gitnexus_query({query: ""}) → Find related execution flows
+4. gitnexus_context({name: ""}) → Deep dive on specific symbol
+5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
+```
+
+> If step 2 says "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] READ gitnexus://repo/{name}/context
+- [ ] gitnexus_query for the concept you want to understand
+- [ ] Review returned processes (execution flows)
+- [ ] gitnexus_context on key symbols for callers/callees
+- [ ] READ process resource for full execution traces
+- [ ] Read source files for implementation details
+```
+
+## Resources
+
+| Resource | What you get |
+| --------------------------------------- | ------------------------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
+| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
+| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
+
+## Tools
+
+**gitnexus_query** — find execution flows related to a concept:
+
+```
+gitnexus_query({query: "profile workspace reconciliation"})
+→ Processes: ProfileLaunchFlow, ContentReconciliation, CasPoolIngestion
+→ Symbols grouped by flow (ContentReconciliationService, CasService, ManifestResolver)
+```
+
+**gitnexus_context** — 360-degree view of a symbol:
+
+```
+gitnexus_context({name: "CasService"})
+→ Incoming calls: ContentReconciliationService, InstallationCasPoolService
+→ Outgoing calls: FileHashProvider, StorageLocationService
+→ Processes: ProfileLaunchFlow (step 2/5), ModInstallationFlow (step 3/4)
+```
+
+## Example: "How does profile launch and workspace reconciliation work?"
+
+```
+1. READ gitnexus://repo/GenHub/context → C# .NET 8 desktop engine, CAS storage, multi-platform runners
+2. gitnexus_query({query: "profile launch reconciliation"})
+ → ProfileLaunchFlow: ProfileLauncherFacade.LaunchProfileAsync → ContentReconciliationService.ReconcileAsync → WineGameProcessManager.StartAsync
+3. gitnexus_context({name: "ContentReconciliationService"})
+ → Incoming: GameLauncher, ProfileLauncherFacade
+ → Outgoing: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+4. Read GenHub/GenHub.Core/Features/Content/ContentReconciliationService.cs for implementation details
+```
diff --git a/.agents/skills/gitnexus-guide/SKILL.md b/.agents/skills/gitnexus-guide/SKILL.md
new file mode 100644
index 000000000..d2743d9e8
--- /dev/null
+++ b/.agents/skills/gitnexus-guide/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: gitnexus-guide
+description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
+---
+
+# GitNexus Guide
+
+Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
+
+## Always Start Here
+
+For any task involving code understanding, debugging, impact analysis, or refactoring:
+
+1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
+2. **Match your task to a skill below** and **read that skill file**
+3. **Follow the skill's workflow and checklist**
+
+> If step 1 warns the index is stale, run `pnpm exec gitnexus analyze` in the terminal first.
+
+## Skills
+
+| Task | Skill to read |
+| -------------------------------------------- | ------------------- |
+| Understand architecture / "How does X work?" | `gitnexus-exploring` |
+| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
+| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
+| Rename / extract / split / refactor | `gitnexus-refactoring` |
+| Tools, resources, schema reference | `gitnexus-guide` (this file) |
+| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
+
+## Tools Reference
+
+| Tool | What it gives you |
+| ---------------- | ------------------------------------------------------------------------ |
+| `query` | Process-grouped code intelligence — execution flows related to a concept |
+| `context` | 360-degree symbol view — categorized refs, processes it participates in |
+| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
+| `detect_changes` | Git-diff impact — what do your current changes affect |
+| `rename` | Multi-file coordinated rename with confidence-tagged edits |
+| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
+| `list_repos` | Discover indexed repos |
+
+## Resources Reference
+
+Lightweight reads (~100-500 tokens) for navigation:
+
+| Resource | Content |
+| ---------------------------------------------- | ----------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness check |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
+| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
+| `gitnexus://repo/{name}/processes` | All execution flows |
+| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
+| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
+
+## Graph Schema
+
+**Nodes:** File, Function, Class, Interface, Method, Community, Process
+**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath
+```
diff --git a/.agents/skills/gitnexus-impact-analysis/SKILL.md b/.agents/skills/gitnexus-impact-analysis/SKILL.md
new file mode 100644
index 000000000..58e015db8
--- /dev/null
+++ b/.agents/skills/gitnexus-impact-analysis/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: gitnexus-impact-analysis
+description: "Use when analyzing blast radius or safety before modifying core GenHub symbols/interfaces (e.g. ICasService, IContentReconciliationService, IGameLauncher). Examples: \"Is it safe to change ICasService?\", \"What depends on ContentReconciliationService?\", \"What will break if I modify GameLauncher?\""
+---
+
+# Impact Analysis with GitNexus
+
+## When to Use
+
+- "Is it safe to modify `ICasService` method signatures?"
+- "What will break if I change `IContentReconciliationService.ReconcileAsync`?"
+- "Show me the blast radius of modifying `IGameProcessManager` across Windows, Linux, and macOS hosts"
+- "Who uses this code?"
+- Before making non-trivial code changes to core abstractions
+- Before committing — to understand what your changes affect
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
+2. READ gitnexus://repo/{name}/processes → Check affected execution flows
+3. gitnexus_detect_changes() → Map current git changes to affected flows
+4. Assess risk and report to user
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
+- [ ] Review d=1 items first (these WILL BREAK)
+- [ ] Check high-confidence (>0.8) dependencies
+- [ ] READ processes to check affected execution flows
+- [ ] gitnexus_detect_changes() for pre-commit check
+- [ ] Assess risk level and report to user
+```
+
+## Understanding Output
+
+| Depth | Risk Level | Meaning |
+| ----- | ---------------- | ------------------------ |
+| d=1 | **WILL BREAK** | Direct callers/importers |
+| d=2 | LIKELY AFFECTED | Indirect dependencies |
+| d=3 | MAY NEED TESTING | Transitive effects |
+
+## Risk Assessment
+
+| Affected | Risk |
+| ------------------------------ | -------- |
+| <5 symbols, few processes | LOW |
+| 5-15 symbols, 2-5 processes | MEDIUM |
+| >15 symbols or many processes | HIGH |
+| Critical path (CAS, launcher, reconciliation, platform runners) | CRITICAL |
+
+## Tools
+
+**gitnexus_impact** — the primary tool for symbol blast radius:
+
+```
+gitnexus_impact({
+ target: "ICasService",
+ direction: "upstream",
+ minConfidence: 0.8,
+ maxDepth: 3
+})
+
+→ d=1 (WILL BREAK):
+ - CasService (GenHub/Services/CasService.cs) [IMPLEMENTS, 100%]
+ - ContentReconciliationService (GenHub/Features/Content/ContentReconciliationService.cs) [CALLS, 100%]
+ - InstallationCasPoolService (GenHub.Core/Features/Storage/InstallationCasPoolService.cs) [CALLS, 100%]
+
+→ d=2 (LIKELY AFFECTED):
+ - GameLauncher (GenHub/Features/Launching/GameLauncher.cs) [CALLS, 95%]
+ - ProfileEditorFacade (GenHub/Features/GameProfiles/ProfileEditorFacade.cs) [CALLS, 90%]
+```
+
+**gitnexus_detect_changes** — git-diff based impact analysis:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+
+→ Changed: 3 symbols in CasService.cs, ICasService.cs
+→ Affected: ProfileLaunchFlow, ContentReconciliationFlow, CasPoolIngestion
+→ Risk: HIGH
+```
+
+## Example: "What breaks if I change ICasService?"
+
+```
+1. gitnexus_impact({target: "ICasService", direction: "upstream"})
+ → d=1: CasService, ContentReconciliationService, InstallationCasPoolService (WILL BREAK)
+ → d=2: GameLauncher, ProfileLauncherFacade (LIKELY AFFECTED)
+
+2. READ gitnexus://repo/GenHub/processes
+ → ProfileLaunchFlow and ModInstallationFlow depend on ICasService
+
+3. Risk: 3 direct dependents, 2 core execution flows = HIGH (Verify callers across Windows, Linux, macOS hosts)
+```
diff --git a/.agents/skills/gitnexus-refactoring/SKILL.md b/.agents/skills/gitnexus-refactoring/SKILL.md
new file mode 100644
index 000000000..ec76c6756
--- /dev/null
+++ b/.agents/skills/gitnexus-refactoring/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: gitnexus-refactoring
+description: "Use when renaming, extracting, splitting, moving, or refactoring code in GenHub safely. Examples: \"Rename ICasStorage method\", \"Extract manifest parser from ContentResolver\", \"Refactor ContentReconciliationService\", \"Split GameLauncher hooks\""
+---
+
+# Refactoring with GitNexus
+
+## When to Use
+
+- "Rename a method on `ICasService` or `IContentReconciliationService` safely"
+- "Extract a CAS pool verification service from `CasService`"
+- "Split platform-specific process launch logic from `GameLauncher`"
+- "Move reconciliation audit helpers to a dedicated service"
+- Any task involving renaming, extracting, splitting, or restructuring code
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
+2. gitnexus_query({query: "X"}) → Find execution flows involving X
+3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
+4. Plan update order: interfaces → implementations → callers → tests
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklists
+
+### Rename Symbol
+
+```
+- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
+- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
+- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
+- [ ] gitnexus_detect_changes() — verify only expected files changed
+- [ ] Run tests for affected processes
+```
+
+### Extract Module / Service
+
+```
+- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
+- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
+- [ ] Define new module interface in GenHub.Core
+- [ ] Extract code, register in DependencyInjection module
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+### Split Function/Service
+
+```
+- [ ] gitnexus_context({name: target}) — understand all callees
+- [ ] Group callees by responsibility
+- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
+- [ ] Create new functions/services
+- [ ] Update callers
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+## Tools
+
+**gitnexus_rename** — automated multi-file rename:
+
+```
+gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+→ 8 edits across 5 files
+→ 6 graph edits (high confidence), 2 ast_search edits (review)
+→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
+```
+
+**gitnexus_impact** — map all dependents first:
+
+```
+gitnexus_impact({target: "ContentReconciliationService", direction: "upstream"})
+→ d=1: GameLauncher, ProfileLauncherFacade, ReconciliationAuditLog
+→ Affected Processes: ProfileLaunchFlow, ProfileWorkspaceReconciliation
+```
+
+**gitnexus_detect_changes** — verify your changes after refactoring:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+→ Changed: 5 files, 8 symbols
+→ Affected processes: ProfileLaunchFlow, WorkspaceReconciliation
+→ Risk: MEDIUM
+```
+
+**gitnexus_cypher** — custom reference queries:
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath ORDER BY caller.filePath
+```
+
+## Risk Rules
+
+| Risk Factor | Mitigation |
+| ------------------- | ----------------------------------------- |
+| Many callers (>5) | Use gitnexus_rename for automated updates |
+| Cross-area refs | Use detect_changes after to verify scope |
+| Platform hosts | Verify composition in Windows, Linux, macOS |
+| External/public API | Check Result pattern contract and error codes |
+
+## Example: Rename `MaterializeFileAsync` to `DeployArtifactAsync`
+
+```
+1. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+ → Preview edits across ICasService.cs, CasService.cs, ContentReconciliationService.cs, and tests
+
+2. Review changes to ensure all cross-platform composition roots and test mocks match
+
+3. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: false})
+ → Applied edits across core interfaces, implementation, and test suites
+
+4. gitnexus_detect_changes({scope: "staged"})
+ → Affected: ProfileLaunchFlow, WorkspaceReconciliation
+ → Risk: MEDIUM — run targeted tests (dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/...)
+```
diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md
new file mode 100644
index 000000000..f1c85008f
--- /dev/null
+++ b/.agents/skills/pull-request/SKILL.md
@@ -0,0 +1,146 @@
+---
+name: pull-request
+description: "Prepares, validates, formats, and opens Pull Requests following repository standards. Use when asked to create a PR, prepare a pull request, open a PR for the current branch, or submit changes."
+---
+
+# Pull Request Creation & Lifecycle
+
+Follow this directed workflow to prepare, validate, format, and open pull requests.
+
+> [!IMPORTANT]
+> **Cardinal Rule:** Never create or open a pull request unless the developer explicitly asks you to do so.
+
+---
+
+## 1. Pre-Flight Checklist
+
+Before opening a PR, verify every item:
+
+- [ ] Explicit developer instruction received to create/open a PR
+- [ ] Working tree is clean with all changes committed (`git status`)
+- [ ] Single concern rule: The PR solves exactly ONE problem (no bundled unrelated refactors)
+- [ ] Branch name follows conventional naming:
+ - `feat/`
+ - `fix/`
+ - `chore/`
+ - `refactor/`
+- [ ] Targeted tests pass locally before pushing
+- [ ] UI changes include before/after screenshots or media recordings
+
+---
+
+## 2. Commit Message Standards
+
+Ensure all commits follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
+
+```
+():
+
+[optional body explaining motivation or context]
+```
+
+### Supported Types:
+- `feat`: New user-facing or architectural capability
+- `fix`: Bug fix
+- `chore`: Build scripts, dependencies, CI configuration, maintenance
+- `refactor`: Code change that neither fixes a bug nor adds a feature
+- `test`: Adding or correcting tests
+- `docs`: Documentation changes only
+- `perf`: Performance improvement
+
+---
+
+## 3. Pull Request Title & Description Template
+
+Construct the PR title and description using the standard template:
+
+### Title Format
+```
+():
+```
+*Example:* `fix(core): handle locked CAS files during background cleanup`
+
+### Body Template
+```markdown
+## Summary
+
+
+### Root Cause
+
+
+
+### Changes
+- ****:
+- ****: ]