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 @@ - + - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + 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 @@ public const int DefaultSystemTimeFontSize = 8; + /// + /// Default volume for money transaction audio events, on the same 0-100 scale the settings + /// screen exposes. Zero would mute them, which is a choice rather than a default. + /// + public const int DefaultMoneyTransactionVolume = 50; + /// /// Default for whether player observer mode is enabled. /// Matches the engine fallback in OptionPreferences::getPlayerObserverEnabled. diff --git a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs index 7f414ba6f..02094c31b 100644 --- a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs @@ -1,3 +1,6 @@ +using System; +using System.Linq; +using GenHub.Core.Constants; using GenHub.Core.Models.GameProfile; namespace GenHub.Core.Extensions; @@ -21,6 +24,35 @@ public static bool HasCustomSettings(this GameProfile profile) HasCustomNetworkSettings(profile); } + /// + /// Checks if a profile runs the GeneralsOnline client. + /// + /// + /// A recorded publisher type settles the question either way. The client name and the enabled + /// content ids are consulted only when no publisher type was recorded, which is the case for + /// profiles created before it existed: a TheSuperHackers profile with GeneralsOnline content + /// enabled belongs to TheSuperHackers, and answering otherwise would let it rewrite the + /// GeneralsOnline client's global settings. + /// + /// The game profile. + /// True if the profile runs GeneralsOnline, false otherwise. + public static bool IsGeneralsOnlineProfile(this GameProfile profile) + { + var publisherType = profile.GameClient?.PublisherType; + if (!string.IsNullOrWhiteSpace(publisherType)) + { + return string.Equals(publisherType, PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase); + } + + if (profile.GameClient?.Name?.Contains(PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase) == true) + { + return true; + } + + return profile.EnabledContentIds? + .Any(id => id.Contains(PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase)) == true; + } + private static bool HasCustomVideoSettings(GameProfile profile) { return profile.VideoResolutionWidth.HasValue || diff --git a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs index a2c705df2..0027abe51 100644 --- a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs +++ b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs @@ -89,10 +89,17 @@ public static void ApplyFromGeneralsOnlineSettings(GeneralsOnlineSettings settin /// Applies settings from a GameProfile to a GeneralsOnlineSettings object. /// Used by GameLauncher to prepare settings.json for launch. /// + /// + /// Only the fields the profile declares are written, as does for + /// Options.ini. The caller passes the settings already on disk, and anything the profile leaves + /// unset is the GeneralsOnline client's own configuration, which a launch must not overwrite. + /// /// The GameProfile source. /// The GeneralsOnlineSettings to populate. public static void ApplyToGeneralsOnlineSettings(GameProfile profile, GeneralsOnlineSettings settings) { + settings.EnsureNestedSectionsInitialized(); + ApplyGoGeneralSettings(profile, settings); ApplyGoCameraAndChatSettings(profile, settings); ApplyGoRenderAndDebugSettings(profile, settings); @@ -607,60 +614,60 @@ private static void ApplyNetworkFromOptions(IniOptions options, GameProfile prof private static void ApplyGoGeneralSettings(GameProfile profile, GeneralsOnlineSettings settings) { - settings.ShowFps = profile.GoShowFps ?? false; - settings.ShowPing = profile.GoShowPing ?? true; - settings.ShowPlayerRanks = profile.GoShowPlayerRanks ?? true; - settings.AutoLogin = profile.GoAutoLogin ?? false; - settings.RememberUsername = profile.GoRememberUsername ?? true; - settings.EnableNotifications = profile.GoEnableNotifications ?? true; - settings.EnableSoundNotifications = profile.GoEnableSoundNotifications ?? true; - settings.ChatFontSize = profile.GoChatFontSize ?? 12; + if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value; + if (profile.GoShowPing.HasValue) settings.ShowPing = profile.GoShowPing.Value; + if (profile.GoShowPlayerRanks.HasValue) settings.ShowPlayerRanks = profile.GoShowPlayerRanks.Value; + if (profile.GoAutoLogin.HasValue) settings.AutoLogin = profile.GoAutoLogin.Value; + if (profile.GoRememberUsername.HasValue) settings.RememberUsername = profile.GoRememberUsername.Value; + if (profile.GoEnableNotifications.HasValue) settings.EnableNotifications = profile.GoEnableNotifications.Value; + if (profile.GoEnableSoundNotifications.HasValue) settings.EnableSoundNotifications = profile.GoEnableSoundNotifications.Value; + if (profile.GoChatFontSize.HasValue) settings.ChatFontSize = profile.GoChatFontSize.Value; } private static void ApplyGoCameraAndChatSettings(GameProfile profile, GeneralsOnlineSettings settings) { - settings.Camera.MaxHeightOnlyWhenLobbyHost = profile.GoCameraMaxHeightOnlyWhenLobbyHost ?? 310.0f; - settings.Camera.MinHeight = profile.GoCameraMinHeight ?? 310.0f; - settings.Camera.MoveSpeedRatio = profile.GoCameraMoveSpeedRatio ?? 1.5f; - settings.Chat.DurationSecondsUntilFadeOut = profile.GoChatDurationSecondsUntilFadeOut ?? 30; + if (profile.GoCameraMaxHeightOnlyWhenLobbyHost.HasValue) settings.Camera.MaxHeightOnlyWhenLobbyHost = profile.GoCameraMaxHeightOnlyWhenLobbyHost.Value; + if (profile.GoCameraMinHeight.HasValue) settings.Camera.MinHeight = profile.GoCameraMinHeight.Value; + if (profile.GoCameraMoveSpeedRatio.HasValue) settings.Camera.MoveSpeedRatio = profile.GoCameraMoveSpeedRatio.Value; + if (profile.GoChatDurationSecondsUntilFadeOut.HasValue) settings.Chat.DurationSecondsUntilFadeOut = profile.GoChatDurationSecondsUntilFadeOut.Value; } private static void ApplyGoRenderAndDebugSettings(GameProfile profile, GeneralsOnlineSettings settings) { - settings.Debug.VerboseLogging = profile.GoDebugVerboseLogging ?? false; - settings.Render.FpsLimit = profile.GoRenderFpsLimit ?? 144; - settings.Render.LimitFramerate = profile.GoRenderLimitFramerate ?? true; - settings.Render.StatsOverlay = profile.GoRenderStatsOverlay ?? true; + if (profile.GoDebugVerboseLogging.HasValue) settings.Debug.VerboseLogging = profile.GoDebugVerboseLogging.Value; + if (profile.GoRenderFpsLimit.HasValue) settings.Render.FpsLimit = profile.GoRenderFpsLimit.Value; + if (profile.GoRenderLimitFramerate.HasValue) settings.Render.LimitFramerate = profile.GoRenderLimitFramerate.Value; + if (profile.GoRenderStatsOverlay.HasValue) settings.Render.StatsOverlay = profile.GoRenderStatsOverlay.Value; } private static void ApplyGoSocialSettings(GameProfile profile, GeneralsOnlineSettings settings) { - settings.Social.NotificationFriendComesOnlineGameplay = profile.GoSocialNotificationFriendComesOnlineGameplay ?? true; - settings.Social.NotificationFriendComesOnlineMenus = profile.GoSocialNotificationFriendComesOnlineMenus ?? true; - settings.Social.NotificationFriendGoesOfflineGameplay = profile.GoSocialNotificationFriendGoesOfflineGameplay ?? true; - settings.Social.NotificationFriendGoesOfflineMenus = profile.GoSocialNotificationFriendGoesOfflineMenus ?? true; - settings.Social.NotificationPlayerAcceptsRequestGameplay = profile.GoSocialNotificationPlayerAcceptsRequestGameplay ?? true; - settings.Social.NotificationPlayerAcceptsRequestMenus = profile.GoSocialNotificationPlayerAcceptsRequestMenus ?? true; - settings.Social.NotificationPlayerSendsRequestGameplay = profile.GoSocialNotificationPlayerSendsRequestGameplay ?? true; - settings.Social.NotificationPlayerSendsRequestMenus = profile.GoSocialNotificationPlayerSendsRequestMenus ?? true; + if (profile.GoSocialNotificationFriendComesOnlineGameplay.HasValue) settings.Social.NotificationFriendComesOnlineGameplay = profile.GoSocialNotificationFriendComesOnlineGameplay.Value; + if (profile.GoSocialNotificationFriendComesOnlineMenus.HasValue) settings.Social.NotificationFriendComesOnlineMenus = profile.GoSocialNotificationFriendComesOnlineMenus.Value; + if (profile.GoSocialNotificationFriendGoesOfflineGameplay.HasValue) settings.Social.NotificationFriendGoesOfflineGameplay = profile.GoSocialNotificationFriendGoesOfflineGameplay.Value; + if (profile.GoSocialNotificationFriendGoesOfflineMenus.HasValue) settings.Social.NotificationFriendGoesOfflineMenus = profile.GoSocialNotificationFriendGoesOfflineMenus.Value; + if (profile.GoSocialNotificationPlayerAcceptsRequestGameplay.HasValue) settings.Social.NotificationPlayerAcceptsRequestGameplay = profile.GoSocialNotificationPlayerAcceptsRequestGameplay.Value; + if (profile.GoSocialNotificationPlayerAcceptsRequestMenus.HasValue) settings.Social.NotificationPlayerAcceptsRequestMenus = profile.GoSocialNotificationPlayerAcceptsRequestMenus.Value; + if (profile.GoSocialNotificationPlayerSendsRequestGameplay.HasValue) settings.Social.NotificationPlayerSendsRequestGameplay = profile.GoSocialNotificationPlayerSendsRequestGameplay.Value; + if (profile.GoSocialNotificationPlayerSendsRequestMenus.HasValue) settings.Social.NotificationPlayerSendsRequestMenus = profile.GoSocialNotificationPlayerSendsRequestMenus.Value; } private static void ApplyGoTshSettings(GameProfile profile, GeneralsOnlineSettings settings) { - settings.ArchiveReplays = profile.TshArchiveReplays ?? false; - settings.MoneyTransactionVolume = profile.TshMoneyTransactionVolume ?? 50; - settings.ShowMoneyPerMinute = profile.TshShowMoneyPerMinute ?? false; - settings.PlayerObserverEnabled = profile.TshPlayerObserverEnabled ?? GameSettingsTheSuperHackersConstants.DefaultPlayerObserverEnabled; - settings.SystemTimeFontSize = profile.TshSystemTimeFontSize ?? GameSettingsTheSuperHackersConstants.DefaultSystemTimeFontSize; - settings.NetworkLatencyFontSize = profile.TshNetworkLatencyFontSize ?? GameSettingsTheSuperHackersConstants.DefaultNetworkLatencyFontSize; - settings.RenderFpsFontSize = profile.TshRenderFpsFontSize ?? GameSettingsTheSuperHackersConstants.DefaultRenderFpsFontSize; - settings.ResolutionFontAdjustment = profile.TshResolutionFontAdjustment ?? GameSettingsTheSuperHackersConstants.DefaultResolutionFontAdjustment; - settings.CursorCaptureEnabledInFullscreenGame = profile.TshCursorCaptureEnabledInFullscreenGame ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenGame; - settings.CursorCaptureEnabledInFullscreenMenu = profile.TshCursorCaptureEnabledInFullscreenMenu ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenMenu; - settings.CursorCaptureEnabledInWindowedGame = profile.TshCursorCaptureEnabledInWindowedGame ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedGame; - settings.CursorCaptureEnabledInWindowedMenu = profile.TshCursorCaptureEnabledInWindowedMenu ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedMenu; - settings.ScreenEdgeScrollEnabledInFullscreenApp = profile.TshScreenEdgeScrollEnabledInFullscreenApp ?? GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInFullscreenApp; - settings.ScreenEdgeScrollEnabledInWindowedApp = profile.TshScreenEdgeScrollEnabledInWindowedApp ?? GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp; + if (profile.TshArchiveReplays.HasValue) settings.ArchiveReplays = profile.TshArchiveReplays.Value; + if (profile.TshMoneyTransactionVolume.HasValue) settings.MoneyTransactionVolume = profile.TshMoneyTransactionVolume.Value; + if (profile.TshShowMoneyPerMinute.HasValue) settings.ShowMoneyPerMinute = profile.TshShowMoneyPerMinute.Value; + if (profile.TshPlayerObserverEnabled.HasValue) settings.PlayerObserverEnabled = profile.TshPlayerObserverEnabled.Value; + if (profile.TshSystemTimeFontSize.HasValue) settings.SystemTimeFontSize = profile.TshSystemTimeFontSize.Value; + if (profile.TshNetworkLatencyFontSize.HasValue) settings.NetworkLatencyFontSize = profile.TshNetworkLatencyFontSize.Value; + if (profile.TshRenderFpsFontSize.HasValue) settings.RenderFpsFontSize = profile.TshRenderFpsFontSize.Value; + if (profile.TshResolutionFontAdjustment.HasValue) settings.ResolutionFontAdjustment = profile.TshResolutionFontAdjustment.Value; + if (profile.TshCursorCaptureEnabledInFullscreenGame.HasValue) settings.CursorCaptureEnabledInFullscreenGame = profile.TshCursorCaptureEnabledInFullscreenGame.Value; + if (profile.TshCursorCaptureEnabledInFullscreenMenu.HasValue) settings.CursorCaptureEnabledInFullscreenMenu = profile.TshCursorCaptureEnabledInFullscreenMenu.Value; + if (profile.TshCursorCaptureEnabledInWindowedGame.HasValue) settings.CursorCaptureEnabledInWindowedGame = profile.TshCursorCaptureEnabledInWindowedGame.Value; + if (profile.TshCursorCaptureEnabledInWindowedMenu.HasValue) settings.CursorCaptureEnabledInWindowedMenu = profile.TshCursorCaptureEnabledInWindowedMenu.Value; + if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) settings.ScreenEdgeScrollEnabledInFullscreenApp = profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value; + if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) settings.ScreenEdgeScrollEnabledInWindowedApp = profile.TshScreenEdgeScrollEnabledInWindowedApp.Value; } private static void ApplyVideoResolutionAndQualityToOptions(GameProfile profile, IniOptions options, ILogger? logger) diff --git a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs index 1180fe768..b727c4383 100644 --- a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs +++ b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs @@ -1,3 +1,8 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using GenHub.Core.Constants; + namespace GenHub.Core.Models.GameSettings; /// GeneralsOnline game client settings (inherits TheSuperHackers settings plus GeneralsOnline-specific options). @@ -7,25 +12,25 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings public bool ShowFps { get; set; } /// Gets or sets a value indicating whether to show ping/latency. - public bool ShowPing { get; set; } = true; + public bool ShowPing { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPing; /// Gets or sets a value indicating whether to enable auto-login. public bool AutoLogin { get; set; } /// Gets or sets a value indicating whether to remember username. - public bool RememberUsername { get; set; } = true; + public bool RememberUsername { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultRememberUsername; /// Gets or sets a value indicating whether to enable notifications. - public bool EnableNotifications { get; set; } = true; + public bool EnableNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableNotifications; /// Gets or sets the chat font size. - public int ChatFontSize { get; set; } = 12; + public int ChatFontSize { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize; /// Gets or sets a value indicating whether to enable sound notifications. - public bool EnableSoundNotifications { get; set; } = true; + public bool EnableSoundNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableSoundNotifications; /// Gets or sets a value indicating whether to show player ranks. - public bool ShowPlayerRanks { get; set; } = true; + public bool ShowPlayerRanks { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPlayerRanks; /// Gets or sets the camera settings. public CameraSettings Camera { get; set; } = new(); @@ -42,6 +47,27 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings /// Gets or sets the social notification settings. public SocialSettings Social { get; set; } = new(); + /// + /// Gets or sets the settings.json keys this model does not declare. GenHub rewrites the + /// GeneralsOnline client's own settings.json wholesale, so without this the client would + /// lose every option GenHub has no property for. + /// + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; + + /// + /// Replaces nested sections that a settings.json spelled as an explicit null, which is valid + /// JSON and overwrites the initializers, so that merging into this instance cannot throw. + /// + public void EnsureNestedSectionsInitialized() + { + Camera ??= new CameraSettings(); + Chat ??= new ChatSettings(); + Debug ??= new DebugSettings(); + Render ??= new RenderSettings(); + Social ??= new SocialSettings(); + } + /// Nested camera settings. public class CameraSettings { @@ -53,6 +79,10 @@ public class CameraSettings /// Gets or sets the camera move speed ratio. public float MoveSpeedRatio { get; set; } = 1.5f; + + /// Gets or sets the camera keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested chat settings. @@ -60,6 +90,10 @@ public class ChatSettings { /// Gets or sets the chat duration in seconds until fade out. public int DurationSecondsUntilFadeOut { get; set; } = 30; + + /// Gets or sets the chat keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested debug settings. @@ -67,6 +101,10 @@ public class DebugSettings { /// Gets or sets a value indicating whether debug verbose logging is enabled. public bool VerboseLogging { get; set; } + + /// Gets or sets the debug keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested render settings. @@ -80,6 +118,10 @@ public class RenderSettings /// Gets or sets a value indicating whether to render stats overlay. public bool StatsOverlay { get; set; } = true; + + /// Gets or sets the render keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } /// Nested social settings. @@ -108,5 +150,9 @@ public class SocialSettings /// Gets or sets a value indicating whether to show notification when player sends request in menus. public bool NotificationPlayerSendsRequestMenus { get; set; } = true; + + /// Gets or sets the social keys this model does not declare, so they survive a rewrite. + [JsonExtensionData] + public Dictionary AdditionalSettings { get; set; } = []; } } diff --git a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs index 84c318569..ec8144db1 100644 --- a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs +++ b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs @@ -1,3 +1,5 @@ +using GenHub.Core.Constants; + namespace GenHub.Core.Models.GameSettings; /// TheSuperHackers game client settings from Options.ini. @@ -19,7 +21,7 @@ public class TheSuperHackersSettings public bool CursorCaptureEnabledInWindowedMenu { get; set; } /// Gets or sets the volume of money transaction audio events (0-100, 0 to mute). - public int MoneyTransactionVolume { get; set; } + public int MoneyTransactionVolume { get; set; } = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume; /// Gets or sets the font size for network latency display (0 to disable). public int NetworkLatencyFontSize { get; set; } = 8; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs new file mode 100644 index 000000000..ea4054d5a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs @@ -0,0 +1,145 @@ +using GenHub.Core.Constants; +using GenHub.Core.Extensions; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; + +namespace GenHub.Tests.Core.Extensions; + +/// +/// Tests for . +/// +public class GameProfileExtensionsTests +{ + /// + /// Verifies that the publisher type identifies a GeneralsOnline profile regardless of casing. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData("generalsonline")] + [InlineData("GeneralsOnline")] + [InlineData("GENERALSONLINE")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlinePublisher_ReturnsTrue(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that other Zero Hour publishers are not mistaken for GeneralsOnline, which is what + /// kept their launches from overwriting the GeneralsOnline client's settings.json. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsGeneralsOnlineProfile_WithOtherPublisher_ReturnsFalse(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.0.genhub.mod.test"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a recorded publisher settles the question, so a profile belonging to another + /// client is not reclassified by content it happens to enable or by its client name. Answering + /// otherwise would let it rewrite the GeneralsOnline client's global settings. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers)] + [InlineData(CommunityOutpostConstants.PublisherType)] + public void IsGeneralsOnlineProfile_WithOtherPublisherAndGeneralsOnlineHints_ReturnsFalse(string publisherType) + { + // Arrange + var profile = CreateZeroHourProfile( + publisherType, + "GeneralsOnline Compatible", + ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.False(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// client name. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineClientName_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "GeneralsOnline 30Hz", []); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile predating the recorded publisher type is still recognised by its + /// enabled content. Such a profile records no publisher at all, so null is its real shape. + /// + /// The publisher type recorded on the profile's client. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsGeneralsOnlineProfile_WithGeneralsOnlineContent_ReturnsTrue(string? publisherType) + { + // Arrange + var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.9.generalsonline.gameclient.30hz"]); + + // Act & Assert + Assert.True(profile.IsGeneralsOnlineProfile()); + } + + /// + /// Verifies that a profile with no client at all, which is the shape the settings editor sees + /// while a profile is being created, falls back to its enabled content. + /// + /// The content the profile enables. + /// Whether that content makes it a GeneralsOnline profile. + [Theory] + [InlineData("1.9.generalsonline.gameclient.30hz", true)] + [InlineData("1.0.genhub.mod.test", false)] + public void IsGeneralsOnlineProfile_WithoutGameClient_FallsBackToContent(string contentId, bool expected) + { + // Arrange + var profile = new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + EnabledContentIds = [contentId], + }; + + // Act & Assert + Assert.Equal(expected, profile.IsGeneralsOnlineProfile()); + } + + private static GameProfile CreateZeroHourProfile(string? publisherType, string clientName, List enabledContentIds) + { + return new GameProfile + { + Id = "profile-1", + Name = "Test Profile", + GameClient = new GameClient + { + Id = "client-1", + Name = clientName, + GameType = GameType.ZeroHour, + PublisherType = publisherType, + }, + EnabledContentIds = enabledContentIds, + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs index fa0903692..206abcbb4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameSettingsViewModelTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using GenHub.Core.Constants; using GenHub.Core.Extensions; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; @@ -395,6 +397,461 @@ public async Task SaveSettings_Should_HandleFailureGracefullyAsync() Assert.Contains("Failed to save settings", _viewModel.StatusMessage); } + /// + /// Should keep settings.json keys the view model does not model when saving over them. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_PreserveUnknownGeneralsOnlineKeysAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings(); + existing.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"preserve-me\""); + + _gameSettingsServiceMock.Setup(x => x.LoadOptionsAsync(GameType.ZeroHour)) + .ReturnsAsync(OperationResult.CreateSuccess(new IniOptions())); + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("preserve-me", saved.AdditionalSettings["auth_token"].GetString()); + } + + /// + /// Should leave settings.json alone when it could not be read, because a missing file reads as + /// defaults and reports success: a failed read means the client's own file exists and is + /// unreadable, and rewriting it from defaults would discard everything the client owns. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotRewriteGeneralsOnlineSettings_WhenTheyCannotBeReadAsync() + { + // Arrange + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // The file was readable when the editor opened and is not when the save reads it again + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("settings.json is locked", _viewModel.StatusMessage); + } + + /// + /// Should save a settings.json that spells a nested section as an explicit null, which is + /// valid JSON and leaves the section null once deserialized. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_HandleNullGeneralsOnlineSectionsAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings { Camera = null!, Chat = null!, Debug = null!, Render = null!, Social = null! }; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoCameraMinHeight = 200.0f; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.Equal(200.0f, saved.Camera.MinHeight); + Assert.Contains("saved successfully", _viewModel.StatusMessage); + } + + /// + /// Should read settings.json again immediately before rewriting it, rather than reusing what + /// initialization read. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReadGeneralsOnlineSettings_BeforeRewritingAsync() + { + // Arrange + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert - once to seed the view model, once more as the baseline for the rewrite + _gameSettingsServiceMock.Verify(x => x.LoadGeneralsOnlineSettingsAsync(), Times.Exactly(2)); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.Is(s => s.ShowFps)), + Times.Once); + } + + /// + /// Should build every save on what settings.json holds at that moment, so that changes the + /// GeneralsOnline client made while this editor was open are not reverted by the rewrite. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_RewriteWhatSettingsJsonHoldsNow_NotWhatItHeldAtInitializationAsync() + { + // Arrange + var atInitialization = new GeneralsOnlineSettings(); + atInitialization.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"old-token\""); + + var writtenByTheClientSince = new GeneralsOnlineSettings { ChatFontSize = 24 }; + writtenByTheClientSince.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"new-token\""); + + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(atInitialization)) + .ReturnsAsync(OperationResult.CreateSuccess(writtenByTheClientSince)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("new-token", saved.AdditionalSettings["auth_token"].GetString()); + } + + /// + /// Should leave settings.json alone when the view model was never seeded from it, because the + /// view model has no unset state and would otherwise write its own defaults over every option + /// the user configured inside the GeneralsOnline client. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotRewriteGeneralsOnlineSettings_WhenSeedingFailedAsync() + { + // Arrange - the read fails while the view model is seeded, then recovers before the save + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("GeneralsOnline settings not written", _viewModel.StatusMessage); + } + + /// + /// Should never report that nothing was saved once Options.ini has been written, because the + /// Options.ini write happens before the settings.json rewrite is gated and a user told the save + /// failed outright would redo work that is already on disk. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotReportTotalFailure_WhenOnlyTheGeneralsOnlineWriteIsSkippedAsync() + { + // Arrange - seeding fails, so the save may not rewrite settings.json + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("never read", _viewModel.StatusMessage); + Assert.True(_viewModel.OptionsFileExists); + } + + /// + /// Should report Options.ini as written when the settings.json rewrite itself is refused, which + /// is the same split outcome as a refused read reached through a later step. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportOptionsIniSaved_WhenTheGeneralsOnlineWriteFailsAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is read-only")); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini saved", _viewModel.StatusMessage); + Assert.Contains("settings.json is read-only", _viewModel.StatusMessage); + } + + /// + /// Should report settings.json as written when it is the Options.ini write that fails, because + /// the rewrite is attempted regardless of how the Options.ini write went. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportGeneralsOnlineSaved_WhenTheOptionsIniWriteFailsAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Options.ini is read-only")); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.DoesNotContain("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("GeneralsOnline settings saved", _viewModel.StatusMessage); + Assert.Contains("Options.ini is read-only", _viewModel.StatusMessage); + } + + /// + /// Should still report a plain failure when neither file was written, so the split reporting + /// does not soften an outcome where nothing landed. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_ReportTotalFailure_WhenNeitherFileIsWrittenAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Options.ini is read-only")); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is read-only")); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", CreateGeneralsOnlineProfile()); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.Contains("Failed to save settings", _viewModel.StatusMessage); + Assert.Contains("Options.ini is read-only", _viewModel.StatusMessage); + Assert.Contains("settings.json is read-only", _viewModel.StatusMessage); + } + + /// + /// Should not carry one profile's settings.json read into the next profile, because saving the + /// second profile would then rewrite the file from a reading taken for the first. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeForProfileAsync_Should_NotReuseThePreviousProfilesSettingsAsync() + { + // Arrange + _gameSettingsServiceMock.SetupSequence(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var first = CreateGeneralsOnlineProfile(); + first.GoShowFps = true; + + var second = CreateGeneralsOnlineProfile(); + second.Id = "go-profile-2"; + second.GoShowFps = false; + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", first); + await _viewModel.InitializeForProfileAsync("go-profile-2", second); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Should keep the values a user configured inside the GeneralsOnline client when saving a + /// profile that declares only some GeneralsOnline options. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotOverwriteClientValues_TheProfileDoesNotDeclareAsync() + { + // Arrange - the client's values are all the opposite of the view model's defaults + var existing = new GeneralsOnlineSettings + { + ShowPing = false, + ShowPlayerRanks = false, + RememberUsername = false, + EnableNotifications = false, + EnableSoundNotifications = false, + ChatFontSize = 24, + }; + + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + Assert.True(saved.ShowFps); + Assert.False(saved.ShowPing); + Assert.False(saved.ShowPlayerRanks); + Assert.False(saved.RememberUsername); + Assert.False(saved.EnableNotifications); + Assert.False(saved.EnableSoundNotifications); + Assert.Equal(24, saved.ChatFontSize); + } + + /// + /// Should not turn the client's enabled toggles off when nothing has read them, which is what + /// a view model default of false would do to a model that defaults them to true. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveSettings_Should_NotFlipEnabledTogglesOffAsync() + { + // Arrange - settings.json does not exist yet, which reads as defaults, so the defaults decide + var profile = CreateGeneralsOnlineProfile(); + profile.GoShowFps = true; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(GameType.ZeroHour, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("go-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + Assert.NotNull(saved); + var expected = new GeneralsOnlineSettings(); + Assert.Equal(expected.ShowPing, saved.ShowPing); + Assert.Equal(expected.ShowPlayerRanks, saved.ShowPlayerRanks); + Assert.Equal(expected.RememberUsername, saved.RememberUsername); + Assert.Equal(expected.EnableNotifications, saved.EnableNotifications); + Assert.Equal(expected.EnableSoundNotifications, saved.EnableSoundNotifications); + Assert.Equal(expected.ChatFontSize, saved.ChatFontSize); + } + + /// + /// Should leave the GeneralsOnline client's global settings.json alone when the profile being + /// edited runs some other client. + /// + /// The publisher the profile's client belongs to. + /// The game the profile targets. + /// A representing the asynchronous operation. + [Theory] + [InlineData(PublisherTypeConstants.TheSuperHackers, GameType.ZeroHour)] + [InlineData(CommunityOutpostConstants.PublisherType, GameType.ZeroHour)] + [InlineData(PublisherTypeConstants.TheSuperHackers, GameType.Generals)] + public async Task SaveSettings_Should_NotWriteGeneralsOnlineSettings_ForOtherPublishersAsync(string publisherType, GameType gameType) + { + // Arrange + var profile = new GameProfile + { + Id = "other-profile", + Name = "Other Profile", + GameClient = new GameClient { GameType = gameType, PublisherType = publisherType }, + VideoResolutionWidth = 1920, + }; + + _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + await _viewModel.InitializeForProfileAsync("other-profile", profile); + await _viewModel.SaveSettingsCommand.ExecuteAsync(null); + + // Assert + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + Assert.Contains("saved successfully", _viewModel.StatusMessage); + } + /// /// Should update selected preset when resolution matches preset. /// @@ -418,4 +875,18 @@ public void ApplyOptionsToViewModel_Should_UpdateSelectedPreset_WhenResolutionMa // Assert Assert.Equal("1920x1080", _viewModel.SelectedResolutionPreset); } + + private static GameProfile CreateGeneralsOnlineProfile() + { + return new GameProfile + { + Id = "go-profile", + Name = "GeneralsOnline Profile", + GameClient = new GameClient + { + GameType = GameType.ZeroHour, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs index 1b8831de0..99b3f6dd2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameSettings/GameSettingsServiceTests.cs @@ -1,9 +1,11 @@ +using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameSettings; using GenHub.Features.GameSettings; using Microsoft.Extensions.Logging; using Moq; +using Moq.Protected; namespace GenHub.Tests.Core.Features.GameSettings; @@ -385,4 +387,160 @@ public async Task SaveOptionsAsync_Should_PreserveUnknownSectionsAsync() Assert.Contains("CustomKey=CustomValue", savedContent); Assert.Contains("AnotherKey=AnotherValue", savedContent); } + + /// + /// Should replace settings.json by moving a completed file over it, leaving nothing behind, + /// because a half-written settings.json costs the GeneralsOnline client every key it owns. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_ReplaceTheFileWithoutTruncatingItAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + await File.WriteAllTextAsync(settingsPath, "{ \"chat_font_size\": 8 }"); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var result = await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = 24 }); + + // Assert + Assert.True(result.Success, result.FirstError); + var reloaded = await service.LoadGeneralsOnlineSettingsAsync(); + Assert.True(reloaded.Success, reloaded.FirstError); + Assert.Equal(24, reloaded.Data!.ChatFontSize); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should report success for every one of a set of concurrent saves, which two GeneralsOnline + /// launches produce because the launch lock is per profile while settings.json is a single + /// global file. Which save wins is not defined, but none of them may be turned away: a launch + /// that reports a settings failure has lost the settings the user chose for that profile. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_SucceedForEverySave_WhenSavesOverlapAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var fontSizes = Enumerable.Range( + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize - GameSettingsGeneralsOnlineConstants.MinChatFontSize); + var results = await Task.WhenAll( + fontSizes.Select(fontSize => service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = fontSize }))); + + // Assert + Assert.All(results, result => Assert.True(result.Success, result.FirstError)); + var reloaded = await service.LoadGeneralsOnlineSettingsAsync(); + Assert.True(reloaded.Success, reloaded.FirstError); + Assert.InRange( + reloaded.Data!.ChatFontSize, + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should keep both concurrent saves and concurrent loads working against the one global + /// settings.json. A load that overlaps the replacement of the file it is reading is the + /// other half of the same race, because the GameLauncher reads settings.json before every + /// save it makes. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task GeneralsOnlineSettings_Should_SucceedForEveryCall_WhenLoadsAndSavesOverlapAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize }); + + try + { + // Act + var fontSizes = Enumerable.Range( + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize - GameSettingsGeneralsOnlineConstants.MinChatFontSize) + .ToList(); + var saves = Task.WhenAll(fontSizes.Select(fontSize => service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings { ChatFontSize = fontSize }))); + var loads = Task.WhenAll(fontSizes.Select(_ => service.LoadGeneralsOnlineSettingsAsync())); + var saveResults = await saves; + var loadResults = await loads; + + // Assert + Assert.All(saveResults, result => Assert.True(result.Success, result.FirstError)); + Assert.All(loadResults, result => Assert.True(result.Success, result.FirstError)); + Assert.All( + loadResults, + result => Assert.InRange( + result.Data!.ChatFontSize, + GameSettingsGeneralsOnlineConstants.MinChatFontSize, + GameSettingsGeneralsOnlineConstants.MaxChatFontSize)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + /// + /// Should report the failure once a replacement that cannot succeed has used up its + /// attempts, rather than retrying a real fault forever or claiming a save that never + /// happened, and should leave no temporary file behind when it does. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SaveGeneralsOnlineSettingsAsync_Should_ReportFailure_WhenTheReplacementNeverSucceedsAsync() + { + // Arrange + var directory = Directory.CreateTempSubdirectory().FullName; + var settingsPath = Path.Combine(directory, GameSettingsGeneralsOnlineConstants.SettingsFileName); + Directory.CreateDirectory(settingsPath); + var service = CreateServiceWritingGeneralsOnlineSettingsTo(settingsPath); + + try + { + // Act + var result = await service.SaveGeneralsOnlineSettingsAsync(new GeneralsOnlineSettings()); + + // Assert + Assert.False(result.Success); + Assert.Empty(Directory.GetFiles(directory, $"*{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}")); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private GameSettingsService CreateServiceWritingGeneralsOnlineSettingsTo(string settingsPath) + { + var mockService = new Mock(MockBehavior.Loose, _loggerMock.Object, _pathProviderMock.Object) + { + CallBase = true, + }; + mockService.Protected().Setup("GetGeneralsOnlineSettingsPath").Returns(settingsPath); + return mockService.Object; + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs index 64f020d74..d178079dd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Text.Json; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; @@ -91,6 +93,10 @@ public GameLauncherTests() .ReturnsAsync(OperationResult.CreateSuccess(new IniOptions())); _gameSettingsServiceMock.Setup(x => x.SaveOptionsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(OperationResult.CreateSuccess(true)); + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(new GeneralsOnlineSettings())); + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); // Setup storage location service mock _storageLocationServiceMock.Setup(x => x.GetWorkspacePath(It.IsAny())) @@ -896,6 +902,155 @@ public async Task LaunchProfileAsync_WithoutProfileSettings_ShouldStillSaveOptio Times.Once); } + /// + /// Tests that a Zero Hour profile running some other client leaves the GeneralsOnline + /// client's settings.json alone, even when its name would match the heuristic that + /// identifies profiles with no recorded publisher. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithNonGeneralsOnlineZeroHourProfile_ShouldNotWriteGeneralsOnlineSettingsAsync() + { + // Arrange + var profile = CreateZeroHourProfile(PublisherTypeConstants.TheSuperHackers, "GeneralsOnline-compatible TheSuperHackers"); + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Tests that a GeneralsOnline profile does write its client settings. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithGeneralsOnlineProfile_ShouldWriteGeneralsOnlineSettingsAsync() + { + // Arrange + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.Is(s => s.ShowFps)), + Times.Once); + } + + /// + /// Tests that settings.json is left alone when it could not be read. A missing file reads as + /// defaults and reports success, so a failed read means the client's own file exists and is + /// unreadable, and rewriting it from defaults would discard everything the client owns. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithUnreadableGeneralsOnlineSettings_ShouldNotRewriteThemAsync() + { + // Arrange + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateFailure("settings.json is locked")); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + _gameSettingsServiceMock.Verify( + x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny()), + Times.Never); + } + + /// + /// Tests that a settings.json spelling a nested section as an explicit null, which is valid + /// JSON, does not break the merge the launch performs. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithNullGeneralsOnlineSection_ShouldStillWriteSettingsAsync() + { + // Arrange + var existing = new GeneralsOnlineSettings { Camera = null! }; + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoCameraMinHeight = 200.0f; + ArrangeSuccessfulLaunch(profile); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.NotNull(saved); + Assert.Equal(200.0f, saved.Camera.MinHeight); + } + + /// + /// Tests that the values a user configured inside the GeneralsOnline client survive a launch + /// of a profile that says nothing about them. + /// + /// The async task. + [Fact] + public async Task LaunchProfileAsync_WithGeneralsOnlineProfile_ShouldPreserveSettingsTheProfileDoesNotSpecifyAsync() + { + // Arrange - every seeded value is the opposite of the GenHub default + var existing = new GeneralsOnlineSettings + { + ShowPing = false, + ChatFontSize = 24, + RememberUsername = false, + }; + existing.Render.FpsLimit = 60; + existing.AdditionalSettings["auth_token"] = JsonSerializer.Deserialize("\"preserve-me\""); + + _gameSettingsServiceMock.Setup(x => x.LoadGeneralsOnlineSettingsAsync()) + .ReturnsAsync(OperationResult.CreateSuccess(existing)); + + var profile = CreateZeroHourProfile(PublisherTypeConstants.GeneralsOnline, "GeneralsOnline"); + profile.GoShowFps = true; + ArrangeSuccessfulLaunch(profile); + + GeneralsOnlineSettings? saved = null; + _gameSettingsServiceMock.Setup(x => x.SaveGeneralsOnlineSettingsAsync(It.IsAny())) + .Callback(s => saved = s) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _gameLauncher.LaunchProfileAsync(profile.Id); + + // Assert + Assert.True(result.Success, result.FirstError); + Assert.NotNull(saved); + Assert.True(saved.ShowFps); + Assert.False(saved.ShowPing); + Assert.Equal(24, saved.ChatFontSize); + Assert.False(saved.RememberUsername); + Assert.Equal(60, saved.Render.FpsLimit); + Assert.True(saved.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.Equal("preserve-me", saved.AdditionalSettings["auth_token"].GetString()); + } + /// /// Removes the temporary retail root. /// @@ -929,6 +1084,31 @@ private static GameProfile CreateTestProfile() }; } + /// + /// Creates a Zero Hour attributed to a specific publisher. + /// + /// The publisher the profile's client belongs to. + /// The client name, which is also consulted when identifying the publisher. + /// A valid Zero Hour . + private static GameProfile CreateZeroHourProfile(string publisherType, string clientName) + { + return new GameProfile + { + Id = Guid.NewGuid().ToString(), + Name = "Test Profile", + GameInstallationId = "install-1", + GameClient = new GameClient + { + Id = "version-1", + Name = clientName, + ExecutablePath = @"C:\Games\generals.exe", + GameType = GameType.ZeroHour, + PublisherType = publisherType, + }, + EnabledContentIds = ["1.0.genhub.mod.test"], + }; + } + private static bool HasArgument(GameLaunchConfiguration? config, string key) { return config?.Arguments is not null && config.Arguments.ContainsKey(key); @@ -964,4 +1144,38 @@ private static void CreateDirectoryAlias(string aliasPath, string targetPath) process.WaitForExit(); Assert.Equal(0, process.ExitCode); } + + /// + /// Wires the mocks a launch needs to reach the settings-writing step and succeed. + /// + /// The profile being launched. + private void ArrangeSuccessfulLaunch(GameProfile profile) + { + var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" }; + var workspaceInfo = new WorkspaceInfo { Id = profile.Id, WorkspacePath = @"C:\workspace" }; + var processInfo = new GameProcessInfo { ProcessId = 123, ProcessName = "generals.exe" }; + + // Zero Hour launches resolve their own installation path, so both roots are declared. + var installation = new GameInstallation(_retailRoot, GameInstallationType.Steam); + installation.SetPaths(_retailRoot, _retailRoot); + _gameInstallationServiceMock.Setup(x => x.GetInstallationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(installation)); + + _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + + _manifestPoolMock.Setup(x => x.GetManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync( + It.Is>(ids => ids.SequenceEqual(TestContentIds)), + It.IsAny())) + .ReturnsAsync(DependencyResolutionResult.CreateSuccess(TestContentIds, [manifest], [])); + + _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo)); + + _processManagerMock.Setup(x => x.StartProcessAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(processInfo)); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs index 96fe358f4..0968e395f 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameSettingsMapperTests.cs @@ -61,56 +61,62 @@ public void ApplyFromOptions_AllReductions_MapsToCorrectQuality(int reduction, T } /// - /// Verifies that a profile with no TheSuperHackers font sizes set falls back to the declared defaults. + /// Verifies that font sizes the profile leaves unset keep the values already in settings.json, + /// which is where the values a user configured inside the client itself live. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_UsesDeclaredDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_PreservesExistingValues() { - // Arrange - seed with values the mapper must overwrite, so a missing assignment fails + // Arrange - seed with values no GenHub default would produce var profile = new GameProfile(); var settings = new GeneralsOnlineSettings { SystemTimeFontSize = 99, - NetworkLatencyFontSize = 99, - RenderFpsFontSize = 99, - ResolutionFontAdjustment = 99, + NetworkLatencyFontSize = 98, + RenderFpsFontSize = 97, + ResolutionFontAdjustment = 96, }; // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultSystemTimeFontSize, settings.SystemTimeFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultNetworkLatencyFontSize, settings.NetworkLatencyFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultRenderFpsFontSize, settings.RenderFpsFontSize); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultResolutionFontAdjustment, settings.ResolutionFontAdjustment); + Assert.Equal(99, settings.SystemTimeFontSize); + Assert.Equal(98, settings.NetworkLatencyFontSize); + Assert.Equal(97, settings.RenderFpsFontSize); + Assert.Equal(96, settings.ResolutionFontAdjustment); } /// - /// Verifies that the fallback defaults match the values declared on the settings model itself. + /// Verifies that GeneralsOnline options the profile leaves unset keep the values already in + /// settings.json rather than being reset to GenHub's defaults. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetFontSizes_MatchesModelDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetGeneralsOnlineOptions_PreservesExistingValues() { - // Arrange - seed with values the mapper must overwrite, so a missing assignment fails - var profile = new GameProfile(); - var expected = new GeneralsOnlineSettings(); + // Arrange - the profile declares one option; everything else is the client's own + var profile = new GameProfile { GoShowFps = true }; var settings = new GeneralsOnlineSettings { - SystemTimeFontSize = 99, - NetworkLatencyFontSize = 99, - RenderFpsFontSize = 99, - ResolutionFontAdjustment = 99, + ShowPing = false, + RememberUsername = false, + ChatFontSize = 24, }; + settings.Camera.MinHeight = 42.0f; + settings.Render.FpsLimit = 60; + settings.Social.NotificationFriendComesOnlineMenus = false; // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(expected.SystemTimeFontSize, settings.SystemTimeFontSize); - Assert.Equal(expected.NetworkLatencyFontSize, settings.NetworkLatencyFontSize); - Assert.Equal(expected.RenderFpsFontSize, settings.RenderFpsFontSize); - Assert.Equal(expected.ResolutionFontAdjustment, settings.ResolutionFontAdjustment); + Assert.True(settings.ShowFps); + Assert.False(settings.ShowPing); + Assert.False(settings.RememberUsername); + Assert.Equal(24, settings.ChatFontSize); + Assert.Equal(42.0f, settings.Camera.MinHeight); + Assert.Equal(60, settings.Render.FpsLimit); + Assert.False(settings.Social.NotificationFriendComesOnlineMenus); } /// @@ -140,47 +146,33 @@ public void ApplyToGeneralsOnlineSettings_ExplicitFontSizes_ArePreserved() } /// - /// Verifies that a profile with no cursor capture, edge scroll or observer toggles set - /// falls back to the declared defaults. + /// Verifies that a fresh settings.json keeps money transaction audio audible, so that the + /// model default and the settings screen agree on what an unconfigured profile writes. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetToggles_UsesDeclaredDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetMoneyTransactionVolume_StaysAudible() { - // Arrange - seed each toggle inverted, so a missing assignment fails + // Arrange var profile = new GameProfile(); - var settings = new GeneralsOnlineSettings - { - PlayerObserverEnabled = false, - CursorCaptureEnabledInFullscreenGame = false, - CursorCaptureEnabledInFullscreenMenu = false, - CursorCaptureEnabledInWindowedGame = false, - CursorCaptureEnabledInWindowedMenu = true, - ScreenEdgeScrollEnabledInFullscreenApp = false, - ScreenEdgeScrollEnabledInWindowedApp = true, - }; + var settings = new GeneralsOnlineSettings(); // Act GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultPlayerObserverEnabled, settings.PlayerObserverEnabled); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenGame, settings.CursorCaptureEnabledInFullscreenGame); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenMenu, settings.CursorCaptureEnabledInFullscreenMenu); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedGame, settings.CursorCaptureEnabledInWindowedGame); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedMenu, settings.CursorCaptureEnabledInWindowedMenu); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInFullscreenApp, settings.ScreenEdgeScrollEnabledInFullscreenApp); - Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp, settings.ScreenEdgeScrollEnabledInWindowedApp); + Assert.Equal(GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume, settings.MoneyTransactionVolume); + Assert.NotEqual(0, settings.MoneyTransactionVolume); } /// - /// Verifies that the toggle fallbacks match the values declared on the settings model itself. + /// Verifies that cursor capture, edge scroll and observer toggles the profile leaves unset + /// keep the values already in settings.json. /// [Fact] - public void ApplyToGeneralsOnlineSettings_UnsetToggles_MatchesModelDefaults() + public void ApplyToGeneralsOnlineSettings_UnsetToggles_PreservesExistingValues() { - // Arrange - seed each toggle inverted, so a missing assignment fails + // Arrange - seed each toggle inverted relative to its GenHub default var profile = new GameProfile(); - var expected = new GeneralsOnlineSettings(); var settings = new GeneralsOnlineSettings { PlayerObserverEnabled = false, @@ -196,13 +188,13 @@ public void ApplyToGeneralsOnlineSettings_UnsetToggles_MatchesModelDefaults() GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); // Assert - Assert.Equal(expected.PlayerObserverEnabled, settings.PlayerObserverEnabled); - Assert.Equal(expected.CursorCaptureEnabledInFullscreenGame, settings.CursorCaptureEnabledInFullscreenGame); - Assert.Equal(expected.CursorCaptureEnabledInFullscreenMenu, settings.CursorCaptureEnabledInFullscreenMenu); - Assert.Equal(expected.CursorCaptureEnabledInWindowedGame, settings.CursorCaptureEnabledInWindowedGame); - Assert.Equal(expected.CursorCaptureEnabledInWindowedMenu, settings.CursorCaptureEnabledInWindowedMenu); - Assert.Equal(expected.ScreenEdgeScrollEnabledInFullscreenApp, settings.ScreenEdgeScrollEnabledInFullscreenApp); - Assert.Equal(expected.ScreenEdgeScrollEnabledInWindowedApp, settings.ScreenEdgeScrollEnabledInWindowedApp); + Assert.False(settings.PlayerObserverEnabled); + Assert.False(settings.CursorCaptureEnabledInFullscreenGame); + Assert.False(settings.CursorCaptureEnabledInFullscreenMenu); + Assert.False(settings.CursorCaptureEnabledInWindowedGame); + Assert.True(settings.CursorCaptureEnabledInWindowedMenu); + Assert.False(settings.ScreenEdgeScrollEnabledInFullscreenApp); + Assert.True(settings.ScreenEdgeScrollEnabledInWindowedApp); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs index fdc156cbf..bbd847342 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameSettings/GeneralsOnlineSettingsTests.cs @@ -89,4 +89,66 @@ public void Serialization_Should_ProduceNestedSnakeCase() Assert.Contains("\"fps_limit\": 144", json); Assert.Contains("\"verbose_logging\": true", json); } + + /// + /// Verifies that settings.json keys this model does not declare survive a load-modify-save + /// round trip, because saving replaces the GeneralsOnline client's file wholesale. + /// + [Fact] + public void RoundTrip_Should_PreserveUnknownKeys() + { + // Arrange + var json = @" +{ + ""show_ping"": true, + ""auth_token"": ""secret"", + ""unmodelled_toggle"": false, + ""camera"": { + ""min_height"": 100.0, + ""unmodelled_zoom_step"": 7 + } +}"; + + // Act + var settings = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(settings); + settings.ShowPing = false; + var rewritten = JsonSerializer.Serialize(settings, _options); + var reloaded = JsonSerializer.Deserialize(rewritten, _options); + + // Assert + Assert.NotNull(reloaded); + Assert.False(reloaded.ShowPing); + Assert.Equal(100.0f, reloaded.Camera.MinHeight); + Assert.True(reloaded.AdditionalSettings.ContainsKey("auth_token"), "client-owned key was dropped"); + Assert.True(reloaded.AdditionalSettings.ContainsKey("unmodelled_toggle"), "client-owned key was dropped"); + Assert.True(reloaded.Camera.AdditionalSettings.ContainsKey("unmodelled_zoom_step"), "client-owned nested key was dropped"); + Assert.Equal("secret", reloaded.AdditionalSettings["auth_token"].GetString()); + Assert.False(reloaded.AdditionalSettings["unmodelled_toggle"].GetBoolean()); + Assert.Equal(7, reloaded.Camera.AdditionalSettings["unmodelled_zoom_step"].GetInt32()); + } + + /// + /// Verifies that a section spelled as an explicit null, which is valid JSON and overwrites the + /// property initializer, is restored so that merging into the loaded settings cannot throw. + /// + [Fact] + public void EnsureNestedSectionsInitialized_Should_ReplaceSectionsDeserializedAsNull() + { + // Arrange + var json = @"{ ""camera"": null, ""chat"": null, ""debug"": null, ""render"": null, ""social"": null }"; + var settings = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(settings); + Assert.Null(settings.Camera); + + // Act + settings.EnsureNestedSectionsInitialized(); + + // Assert + Assert.NotNull(settings.Camera); + Assert.NotNull(settings.Chat); + Assert.NotNull(settings.Debug); + Assert.NotNull(settings.Render); + Assert.NotNull(settings.Social); + } } diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 7e36d6669..51e951e73 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs @@ -883,7 +883,7 @@ private async Task> ReconcilePublisherClient } else { - if (IsGeneralsOnlineProfile(profile)) + if (profile.IsGeneralsOnlineProfile()) { publisherType = PublisherTypeConstants.GeneralsOnline; reconciler = reconcilerRegistry.GetReconciler(publisherType); @@ -1236,36 +1236,6 @@ private string BuildVersionRequirementString(ContentDependency dependency) return parts.Count > 0 ? $"({string.Join(" and ", parts)})" : string.Empty; } - /// - /// Checks if a profile uses a GeneralsOnline game client. - /// - /// The profile to check. - /// True if the profile uses GeneralsOnline, false otherwise. - private bool IsGeneralsOnlineProfile(GameProfile profile) - { - // Check PublisherType first - if (profile.GameClient?.PublisherType?.Equals( - PublisherTypeConstants.GeneralsOnline, - StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Check if Name contains "GeneralsOnline" (for legacy or incomplete profiles) - if (profile.GameClient?.Name?.Contains("GeneralsOnline", StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - - // Final fallback: Check enabled content for GeneralsOnline manifests - if (profile.EnabledContentIds?.Any(id => id.Contains("generalsonline", StringComparison.OrdinalIgnoreCase)) == true) - { - return true; - } - - return false; - } - /// /// Checks if a profile uses a SuperHackers game client. /// diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs index a152638e7..fce0e13b7 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameSettingsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -11,6 +12,7 @@ using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameSettings; +using GenHub.Core.Models.Results; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -316,32 +318,32 @@ partial void OnStaticGameLODChanged(string value) private bool _tshScreenEdgeScrollEnabledInWindowedApp = GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp; [ObservableProperty] - private int _tshMoneyTransactionVolume = 50; + private int _tshMoneyTransactionVolume = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume; // ===== GeneralsOnline Client Settings ===== [ObservableProperty] private bool _goShowFps; [ObservableProperty] - private bool _goShowPing; + private bool _goShowPing = GameSettingsGeneralsOnlineConstants.DefaultShowPing; [ObservableProperty] - private bool _goShowPlayerRanks; + private bool _goShowPlayerRanks = GameSettingsGeneralsOnlineConstants.DefaultShowPlayerRanks; [ObservableProperty] private bool _goAutoLogin; [ObservableProperty] - private bool _goRememberUsername; + private bool _goRememberUsername = GameSettingsGeneralsOnlineConstants.DefaultRememberUsername; [ObservableProperty] - private bool _goEnableNotifications; + private bool _goEnableNotifications = GameSettingsGeneralsOnlineConstants.DefaultEnableNotifications; [ObservableProperty] - private bool _goEnableSoundNotifications; + private bool _goEnableSoundNotifications = GameSettingsGeneralsOnlineConstants.DefaultEnableSoundNotifications; [ObservableProperty] - private int _goChatFontSize = 12; + private int _goChatFontSize = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize; // Camera settings [ObservableProperty] @@ -423,6 +425,8 @@ public async Task InitializeForProfileAsync(string? profileId, Core.Models.GameP try { _currentProfileId = profileId; + _currentProfileIsGeneralsOnline = profile?.IsGeneralsOnlineProfile() == true; + _generalsOnlineSettingsSeeded = false; // Auto-select game type from profile if (profile != null) @@ -457,6 +461,10 @@ public async Task InitializeForProfileAsync(string? profileId, Core.Models.GameP // If profile has settings, load them if (profile?.HasCustomSettings() == true) { + // Seeded from settings.json first so that the options the profile does not declare + // show, and are saved back as, what the user configured inside the GeneralsOnline + // client rather than this view model's defaults. + await LoadGeneralsOnlineSettingsFromClientAsync(); LoadSettingsFromProfile(profile); } else @@ -631,6 +639,8 @@ private async Task TestPat() } private IniOptions? _currentOptions; + private bool _generalsOnlineSettingsSeeded; + private bool _currentProfileIsGeneralsOnline; private string? _currentProfileId; private int _initializationDepth; private bool _isLoadingFromOptions; @@ -689,10 +699,12 @@ private async Task LoadSettings() if (goResult?.Success == true && goResult.Data != null) { ApplyGeneralsOnlineSettings(goResult.Data); + _generalsOnlineSettingsSeeded = true; _logger.LogInformation("Loaded GeneralsOnline settings"); } else { + _generalsOnlineSettingsSeeded = false; var goErrors = goResult?.Errors ?? ["LoadGeneralsOnlineSettings result was null"]; _logger.LogWarning("Failed to load GeneralsOnline settings: {Errors}", string.Join(", ", goErrors)); } @@ -708,6 +720,37 @@ private async Task LoadSettings() } } + /// + /// Reads the GeneralsOnline client's own settings.json into this view model. + /// + /// + /// The view model's GeneralsOnline properties have no unset state, so every one of them is + /// written back on save. Seeding them from the client's file is what keeps that from replacing + /// options the profile says nothing about with defaults. A read that fails leaves the view + /// model unseeded, which is what stops the save from writing over the client's own values. + /// + /// A task representing the asynchronous operation. + private async Task LoadGeneralsOnlineSettingsFromClientAsync() + { + if (_gameSettingsService == null || !_currentProfileIsGeneralsOnline) + { + return; + } + + var goResult = await _gameSettingsService.LoadGeneralsOnlineSettingsAsync(); + if (goResult?.Success == true && goResult.Data != null) + { + ApplyGeneralsOnlineSettings(goResult.Data); + _generalsOnlineSettingsSeeded = true; + } + else + { + _generalsOnlineSettingsSeeded = false; + var goErrors = goResult?.Errors ?? ["LoadGeneralsOnlineSettings result was null"]; + _logger.LogWarning("Failed to load GeneralsOnline settings: {Errors}", string.Join(", ", goErrors)); + } + } + /// /// Loads settings from a game profile. /// @@ -853,8 +896,16 @@ private void LoadGeneralsOnlineSettingsFromProfile(Core.Models.GameProfile.GameP } /// - /// Saves the current settings to options.ini. + /// Saves the current settings to Options.ini and, for a GeneralsOnline profile, to the client's + /// settings.json. /// + /// + /// The two files are separate writes with no transaction between them, so either one can land + /// while the other does not: the settings.json rewrite can be refused after Options.ini is + /// written, and Options.ini can fail after settings.json has been rewritten. Reordering the + /// writes only moves which half is exposed, so the status message names the halves separately + /// instead of reporting a total failure over a file that was written. + /// [RelayCommand] private async Task SaveSettings() { @@ -872,27 +923,66 @@ private async Task SaveSettings() var options = CreateOptionsFromViewModel(); var result = await _gameSettingsService.SaveOptionsAsync(SelectedGameType, options); - // Save GeneralsOnline settings - var goSettings = CreateGeneralsOnlineSettings(); - var goResult = await _gameSettingsService.SaveGeneralsOnlineSettingsAsync(goSettings); + var writeGeneralsOnlineSettings = ShouldWriteGeneralsOnlineSettings(); + OperationResult? goResult = null; + string? goLoadError = null; - if (result?.Success == true && goResult?.Success == true) + if (writeGeneralsOnlineSettings) + { + var goLoadResult = await ReadGeneralsOnlineSettingsForRewriteAsync(); + if (goLoadResult.Success && goLoadResult.Data != null) + { + var goSettings = goLoadResult.Data; + MergeViewModelIntoGeneralsOnlineSettings(goSettings); + goResult = await _gameSettingsService.SaveGeneralsOnlineSettingsAsync(goSettings); + } + else + { + goLoadError = goLoadResult.FirstError; + } + } + + var optionsSaved = result?.Success == true; + var generalsOnlineWritten = goResult?.Success == true; + var generalsOnlineBlocked = writeGeneralsOnlineSettings && !generalsOnlineWritten; + + if (optionsSaved) { _currentOptions = options; OptionsFileExists = true; + } + + var optionsErrors = new List(); + if (result == null) optionsErrors.Add("SaveOptions result was null"); + if (result?.Success == false) optionsErrors.AddRange(result.Errors); + + var generalsOnlineErrors = new List(); + if (goLoadError != null) generalsOnlineErrors.Add(goLoadError); + if (goResult?.Success == false) generalsOnlineErrors.AddRange(goResult.Errors); + if (generalsOnlineBlocked && goLoadError == null && goResult == null) generalsOnlineErrors.Add("SaveGeneralsOnlineSettings result was null"); + + if (optionsSaved && !generalsOnlineBlocked) + { StatusMessage = $"{SelectedGameType} settings saved successfully"; _logger.LogInformation("Saved settings for {GameType}", SelectedGameType); } + else if (optionsSaved) + { + var goErrors = string.Join(", ", generalsOnlineErrors); + StatusMessage = $"Options.ini saved; GeneralsOnline settings not written: {goErrors}"; + _logger.LogWarning("Saved Options.ini for {GameType} but did not write GeneralsOnline settings: {Errors}", SelectedGameType, goErrors); + } + else if (generalsOnlineWritten) + { + var iniErrors = string.Join(", ", optionsErrors); + StatusMessage = $"GeneralsOnline settings saved; Options.ini not saved: {iniErrors}"; + _logger.LogWarning("Wrote GeneralsOnline settings but failed to save Options.ini for {GameType}: {Errors}", SelectedGameType, iniErrors); + } else { - var errors = new List(); - if (result?.Success == false) errors.AddRange(result.Errors); - if (goResult?.Success == false) errors.AddRange(goResult.Errors); - if (result == null) errors.Add("SaveOptions result was null"); - if (goResult == null) errors.Add("SaveGeneralsOnlineSettings result was null"); - - StatusMessage = $"Failed to save settings: {string.Join(", ", errors)}"; - _logger.LogWarning("Failed to save settings: {Errors}", string.Join(", ", errors)); + var errors = string.Join(", ", optionsErrors.Concat(generalsOnlineErrors)); + StatusMessage = $"Failed to save settings: {errors}"; + _logger.LogWarning("Failed to save settings: {Errors}", errors); } } catch (Exception ex) @@ -906,6 +996,43 @@ private async Task SaveSettings() } } + /// + /// Reads the GeneralsOnline client's settings.json so the save can be applied on top of it. + /// + /// + /// The file is read again for every save rather than kept as a snapshot: it is the + /// GeneralsOnline client's own global file, so anything it or another GenHub window wrote + /// since this editor opened would otherwise be reverted by the rewrite. Reading it is also + /// the only way to fail loudly, because a missing file reads as defaults and reports success: + /// a failure therefore means the client's file exists and could not be read, and rewriting it + /// from defaults would discard every key the client owns. + /// + /// The read alone is not enough. This view model has no unset state, so it writes all 24 + /// GeneralsOnline fields; unless they were seeded from a successful read, writing them would + /// replace what the user configured inside the client with this view model's defaults. + /// + /// + /// The settings this save must be applied on top of, or the error that aborts the rewrite. + private async Task> ReadGeneralsOnlineSettingsForRewriteAsync() + { + if (!_generalsOnlineSettingsSeeded) + { + const string error = "GeneralsOnline settings.json was never read, so its values cannot be rewritten"; + _logger.LogWarning("Not writing GeneralsOnline settings: {Error}", error); + return OperationResult.CreateFailure(error); + } + + var goLoadResult = await _gameSettingsService!.LoadGeneralsOnlineSettingsAsync(); + if (goLoadResult?.Success == true && goLoadResult.Data != null) + { + return goLoadResult; + } + + var loadError = goLoadResult?.FirstError ?? "LoadGeneralsOnlineSettings result was null"; + _logger.LogWarning("Not writing GeneralsOnline settings because settings.json could not be read: {Error}", loadError); + return OperationResult.CreateFailure(loadError); + } + /// /// Opens the Options.ini file location in Windows Explorer. /// @@ -1173,6 +1300,8 @@ private IniOptions CreateOptionsFromViewModel() private void ApplyGeneralsOnlineSettings(GeneralsOnlineSettings settings) { + settings.EnsureNestedSectionsInitialized(); + GoShowFps = settings.ShowFps; GoShowPing = settings.ShowPing; GoShowPlayerRanks = settings.ShowPlayerRanks; @@ -1199,20 +1328,34 @@ private void ApplyGeneralsOnlineSettings(GeneralsOnlineSettings settings) GoSocialNotificationPlayerSendsRequestMenus = settings.Social.NotificationPlayerSendsRequestMenus; } - private GeneralsOnlineSettings CreateGeneralsOnlineSettings() + /// + /// Decides whether this save may rewrite settings.json, which is a single global file owned by + /// the GeneralsOnline client rather than a per-profile one. Saving a retail, TheSuperHackers or + /// CommunityOutpost profile must leave it untouched. + /// + /// True when the profile being edited runs the GeneralsOnline client. + private bool ShouldWriteGeneralsOnlineSettings() { - var settings = new GeneralsOnlineSettings - { - ShowFps = GoShowFps, - ShowPing = GoShowPing, - ShowPlayerRanks = GoShowPlayerRanks, - AutoLogin = GoAutoLogin, - RememberUsername = GoRememberUsername, - EnableNotifications = GoEnableNotifications, - EnableSoundNotifications = GoEnableSoundNotifications, - ChatFontSize = GoChatFontSize, - }; + return SelectedGameType == GameType.ZeroHour && _currentProfileIsGeneralsOnline; + } + /// + /// Writes this view model's GeneralsOnline values into settings just read from the client's + /// settings.json, which is what carries the keys this model does not declare through a save. + /// + /// The settings read from settings.json, mutated in place. + private void MergeViewModelIntoGeneralsOnlineSettings(GeneralsOnlineSettings settings) + { + settings.EnsureNestedSectionsInitialized(); + + settings.ShowFps = GoShowFps; + settings.ShowPing = GoShowPing; + settings.ShowPlayerRanks = GoShowPlayerRanks; + settings.AutoLogin = GoAutoLogin; + settings.RememberUsername = GoRememberUsername; + settings.EnableNotifications = GoEnableNotifications; + settings.EnableSoundNotifications = GoEnableSoundNotifications; + settings.ChatFontSize = GoChatFontSize; settings.Camera.MaxHeightOnlyWhenLobbyHost = GoCameraMaxHeightOnlyWhenLobbyHost; settings.Camera.MinHeight = GoCameraMinHeight; settings.Camera.MoveSpeedRatio = GoCameraMoveSpeedRatio; @@ -1229,7 +1372,5 @@ private GeneralsOnlineSettings CreateGeneralsOnlineSettings() settings.Social.NotificationPlayerAcceptsRequestMenus = GoSocialNotificationPlayerAcceptsRequestMenus; settings.Social.NotificationPlayerSendsRequestGameplay = GoSocialNotificationPlayerSendsRequestGameplay; settings.Social.NotificationPlayerSendsRequestMenus = GoSocialNotificationPlayerSendsRequestMenus; - - return settings; } } diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index 2dc4a45d8..c19938893 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -33,6 +33,17 @@ public class GameSettingsService(ILogger logger, IGamePathP /// private static readonly SemaphoreSlim _optionsIniWriteSemaphore = new(1, 1); + /// + /// Static semaphore to serialize settings.json reads and writes across all game launches. + /// The launch lock is per profile, so two GeneralsOnline profiles launching at once both + /// reach this one global file. On Windows that is not a race one writer simply wins: two + /// overlapping replacements of the same destination, or a replacement overlapping a read, + /// fail outright with an access denial, and the launch loses the settings it meant to save. + /// The lock is released between a load and the save that follows it, so which launch writes + /// last is still whichever finishes last. + /// + private static readonly SemaphoreSlim _generalsOnlineSettingsSemaphore = new(1, 1); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Required, not optional. This previously defaulted to WindowsGamePathProvider when @@ -209,6 +220,7 @@ public async Task> LoadGeneralsOnlineSet { using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); + await _generalsOnlineSettingsSemaphore.WaitAsync(); try { var settingsPath = GetGeneralsOnlineSettingsPath(); @@ -229,6 +241,8 @@ public async Task> LoadGeneralsOnlineSet return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); } + settings.EnsureNestedSectionsInitialized(); + _logger.LogInformation("Loaded GeneralsOnline settings from {SettingsPath}", settingsPath); return OperationResult.CreateSuccess(settings); } @@ -237,6 +251,10 @@ public async Task> LoadGeneralsOnlineSet _logger.LogError(ex, "Failed to load GeneralsOnline settings"); return OperationResult.CreateFailure($"Failed to load GeneralsOnline settings: {ex.Message}"); } + finally + { + _generalsOnlineSettingsSemaphore.Release(); + } } /// @@ -244,6 +262,8 @@ public async Task> SaveGeneralsOnlineSettingsAsync(General { using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); + string? temporaryPath = null; + await _generalsOnlineSettingsSemaphore.WaitAsync(); try { var settingsPath = GetGeneralsOnlineSettingsPath(); @@ -256,7 +276,15 @@ public async Task> SaveGeneralsOnlineSettingsAsync(General } var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); - await File.WriteAllTextAsync(settingsPath, json, Encoding.UTF8); + + // Written beside settings.json under a name of its own and then moved over it. This + // file belongs to the GeneralsOnline client and holds keys GenHub cannot reconstruct, + // so a truncating write that is interrupted, or that overlaps a second launch writing + // the same path, would leave the client with a settings.json it cannot read. + temporaryPath = $"{settingsPath}.{Guid.NewGuid():N}{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}"; + await File.WriteAllTextAsync(temporaryPath, json, Encoding.UTF8); + await ReplaceSettingsFileAsync(temporaryPath, settingsPath); + temporaryPath = null; _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); return OperationResult.CreateSuccess(true); @@ -266,6 +294,41 @@ public async Task> SaveGeneralsOnlineSettingsAsync(General _logger.LogError(ex, "Failed to save GeneralsOnline settings"); return OperationResult.CreateFailure($"Failed to save GeneralsOnline settings: {ex.Message}"); } + finally + { + DiscardTemporarySettingsFile(temporaryPath); + _generalsOnlineSettingsSemaphore.Release(); + } + } + + /// + /// Gets the path of the GeneralsOnline client's global settings.json. + /// + /// The full path to settings.json. + protected virtual string GetGeneralsOnlineSettingsPath() + { + var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var zeroHourDataPath = Path.Combine(documentsPath, GameSettingsConstants.FolderNames.ZeroHour); + var generalsOnlineDataPath = Path.Combine(zeroHourDataPath, GameSettingsConstants.FolderNames.GeneralsOnlineData); + return Path.Combine(generalsOnlineDataPath, GameSettingsGeneralsOnlineConstants.SettingsFileName); + } + + private static void DiscardTemporarySettingsFile(string? temporaryPath) + { + if (temporaryPath == null || !File.Exists(temporaryPath)) + { + return; + } + + try + { + File.Delete(temporaryPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort; a leftover temporary file is not worth failing the save over, and + // this runs in a finally block where throwing would hide the error being reported. + } } private static IniOptions ParseOptionsIni(string[] lines) @@ -728,14 +791,6 @@ private static Dictionary SerializeTheSuperHackersSettings(TheSu }; } - private static string GetGeneralsOnlineSettingsPath() - { - var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - var zeroHourDataPath = Path.Combine(documentsPath, GameSettingsConstants.FolderNames.ZeroHour); - var generalsOnlineDataPath = Path.Combine(zeroHourDataPath, GameSettingsConstants.FolderNames.GeneralsOnlineData); - return Path.Combine(generalsOnlineDataPath, GameSettingsGeneralsOnlineConstants.SettingsFileName); - } - private static string SanitizeKey(string key) { if (string.IsNullOrEmpty(key)) return key; @@ -749,4 +804,43 @@ private static string SanitizeKey(string key) // Remove any other control characters or non-printable chars if needed return key.Trim(); } + + /// + /// Moves a completed settings file over settings.json, retrying the move a bounded number + /// of times before letting the failure reach the caller. + /// + /// + /// The semaphore keeps GenHub's own saves off each other, but settings.json belongs to the + /// GeneralsOnline client, and a running client, a virus scanner or the search indexer can + /// hold it open. Windows refuses a replacement of a file another handle has open instead of + /// waiting for it, and reports that as an access denial rather than as contention. Every + /// such holder lets go within milliseconds, so a few attempts separated by a short delay + /// tell an overlap apart from a file GenHub genuinely may not write. + /// + /// The completed file to move. + /// The settings.json path to replace. + /// A representing the asynchronous operation. + private async Task ReplaceSettingsFileAsync(string temporaryPath, string settingsPath) + { + for (var attempt = 1; attempt < GameSettingsGeneralsOnlineConstants.SettingsReplaceAttemptLimit; attempt++) + { + try + { + File.Move(temporaryPath, settingsPath, overwrite: true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogDebug( + ex, + "Attempt {Attempt} of {AttemptLimit} to replace {SettingsPath} was refused, retrying", + attempt, + GameSettingsGeneralsOnlineConstants.SettingsReplaceAttemptLimit, + settingsPath); + await Task.Delay(GameSettingsGeneralsOnlineConstants.SettingsReplaceRetryDelayMilliseconds); + } + } + + File.Move(temporaryPath, settingsPath, overwrite: true); + } } diff --git a/GenHub/GenHub/Features/Launching/GameLauncher.cs b/GenHub/GenHub/Features/Launching/GameLauncher.cs index 18e049652..5c7303bbd 100644 --- a/GenHub/GenHub/Features/Launching/GameLauncher.cs +++ b/GenHub/GenHub/Features/Launching/GameLauncher.cs @@ -1606,11 +1606,16 @@ private async Task ApplyProfileSettingsToIniOptionsAsync(GameProfile profile) /// /// Applies GeneralsOnline-specific settings to the settings.json file. /// + /// + /// settings.json is a single global file owned by the GeneralsOnline client, not a + /// per-profile one. Only a GeneralsOnline profile may rewrite it: a retail, TheSuperHackers + /// or CommunityOutpost Zero Hour profile has nothing to say about that client's settings, + /// and writing anyway replaced whatever the user had configured inside the client itself. + /// /// The game profile containing the settings. private async Task ApplyGeneralsOnlineSettingsAsync(GameProfile profile) { - // Only apply if it's Zero Hour (as GO settings only apply there currently) - if (profile.GameClient?.GameType != GameType.ZeroHour) + if (profile.GameClient?.GameType != GameType.ZeroHour || !profile.IsGeneralsOnlineProfile()) { return; } @@ -1619,10 +1624,22 @@ private async Task ApplyGeneralsOnlineSettingsAsync(GameProfile profile) { logger.LogInformation("[GameLauncher] Applying GeneralsOnline settings to settings.json for profile {ProfileId}", profile.Id); - // Clean Launch Strategy: Create fresh settings object to ensure isolation and prevent pollution - var settings = new GeneralsOnlineSettings(); + // Loaded first so the settings the client owns and the profile says nothing about + // survive the rewrite; the mapper then overwrites only what the profile declares. + var loadResult = await gameSettingsService.LoadGeneralsOnlineSettingsAsync(); + if (loadResult?.Success != true || loadResult.Data == null) + { + // A missing settings.json loads as defaults and reports success, so a failure here + // means the client's own file exists and could not be read. Rewriting it from + // defaults would discard every key the client owns. + logger.LogWarning( + "[GameLauncher] Not writing GeneralsOnline settings because settings.json could not be read: {Error}", + loadResult?.FirstError ?? "LoadGeneralsOnlineSettings result was null"); + return; + } + + var settings = loadResult.Data; - // Map GO settings from profile using the centralized mapper GameSettingsMapper.ApplyToGeneralsOnlineSettings(profile, settings); var saveResult = await gameSettingsService.SaveGeneralsOnlineSettingsAsync(settings); diff --git a/docs/dev/game-settings-architecture.md b/docs/dev/game-settings-architecture.md index a22efd072..581068249 100644 --- a/docs/dev/game-settings-architecture.md +++ b/docs/dev/game-settings-architecture.md @@ -65,17 +65,24 @@ These settings are: **Fix**: Implemented in `GameSettingsViewModel.CreateOptionsFromViewModel()` - now preserves existing `AdditionalProperties` and `AdditionalSections`. -### Settings Not Applying from Profile +### GeneralsOnline Client Settings Reset After Launching -**Symptom**: Profile settings don't apply when launching the game. +**Symptom**: Options configured inside the GeneralsOnline client (including ones GenHub has no UI for) revert after launching a profile through GenHub. -**Cause**: The `ApplyToGeneralsOnlineSettings()` mapper was using `if (HasValue)` checks, skipping null values and leaving constructor defaults. +**Cause**: `ApplyToGeneralsOnlineSettings()` coalesced every field with `?? default`, so a launch wrote GenHub's defaults over each option the profile said nothing about, and the write started from a fresh `GeneralsOnlineSettings` instance, which dropped every key the model does not declare. + +**Fix**: `settings.json` is loaded first and merged into, and only the fields the profile actually declares are written: -**Fix**: Changed to use null-coalescing operators with explicit defaults: ```csharp -settings.ShowFps = profile.GoShowFps ?? false; // Always sets a value +if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value; // Merges into what was loaded ``` +Anything the profile leaves unset stays as the client wrote it, and unmodelled keys survive through `[JsonExtensionData]`. The load must succeed before the file is rewritten: a missing file loads as defaults and reports success, so a failed load means the client's file exists and is unreadable, and both `GameLauncher` and `GameSettingsViewModel` skip the write in that case. + +`ApplyToOptions()` writes `Options.ini` the same way, only conditionally, and the keys GenHub does not model are preserved there through `AdditionalProperties` and `AdditionalSections` rather than `[JsonExtensionData]`. + +`GameSettingsViewModel` reads `settings.json` again immediately before each save rather than keeping the copy it read when the editor opened, so a save cannot revert what the client (or another GenHub window) wrote in between. Its GeneralsOnline properties have no unset state, so all of them are written on save; if the read that seeds them fails, the view model skips the rewrite entirely rather than writing its own defaults over the client's values. The save itself is written to a file beside `settings.json` and moved over it, so an interrupted or overlapping write cannot leave a half-written file behind. + ## Overview Adding a single game setting in GenHub involves modifying approximately 7-8 files. While this may seem complex, it adheres to a strict **Separation of Concerns** to ensure robustness, testability, and clear boundaries between data persistence, API contracts, and user interface. @@ -198,9 +205,9 @@ Tracing a setting change (e.g., "Show FPS") from User to Disk: - `GameSettingsService` writes `Options.ini`. *Note: It manually adds the `[TheSuperHackers]` header.* **Path B: To settings.json (GeneralsOnline)** - - Calls `ApplyGeneralsOnlineSettingsAsync`. - - Instantiates new `GeneralsOnlineSettings`. - - Manually maps properties: `settings.ShowFps = profile.GoShowFps.Value;` + - Calls `ApplyGeneralsOnlineSettingsAsync`, which runs only for GeneralsOnline profiles: `settings.json` is a single global file owned by that client, so a retail, TheSuperHackers or CommunityOutpost profile must leave it alone. + - Loads the existing `settings.json` into a `GeneralsOnlineSettings`, and skips the write if it could not be read. + - Merges the declared properties into it: `if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value;` - `GameSettingsService` writes `settings.json` using `System.Text.Json`. ### Inheritance Detail From 836cd662addcd41d569e91bd0b378528aed933ee Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:00:00 -0400 Subject: [PATCH 07/20] fix(launching): adopt a forked game process on Unix as well as Windows (#386) * fix(launching): adopt a forked game process on Unix as well as Windows * test(launching): keep the expected-child launcher test on Windows runners * fix(launching): match Unix game processes through the name the kernel truncates * fix(launching): decide workspace residence against the real directory on disk * fix(launching): require a known launcher start time before adopting a running game * fix(launching): decline adoption at once when the launcher start time is unknown * fix(launching): bound adoption by the launcher start time instead of the recency window --- GenHub/GenHub.Core/Constants/IoConstants.cs | 6 + .../GenHub.Core/Constants/ProcessConstants.cs | 12 +- .../Helpers/GameProcessSelector.cs | 275 +++++++++++++++++- .../GameProfiles/GameProcessManagerTests.cs | 153 +++++++++- .../Helpers/GameProcessSelectorTests.cs | 261 +++++++++++++++++ .../Infrastructure/GameProcessManager.cs | 138 ++++++--- 6 files changed, 785 insertions(+), 60 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs index 09f0b77b5..c49e2e66d 100644 --- a/GenHub/GenHub.Core/Constants/IoConstants.cs +++ b/GenHub/GenHub.Core/Constants/IoConstants.cs @@ -9,4 +9,10 @@ public static class IoConstants /// Default buffer size for file operations (4KB). /// public const int DefaultFileBufferSize = 4096; + + /// + /// How many times a path may be re-resolved while following symbolic links whose targets are + /// themselves reached through links. Bounds the walk on a filesystem that contains a cycle. + /// + public const int MaxSymbolicLinkResolutionDepth = 8; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs index 01d22c943..d4d7187ff 100644 --- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs +++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs @@ -83,10 +83,18 @@ public static class ProcessConstants /// public const double EarlyExitThresholdSeconds = 10.0; + /// + /// How many characters of a process name a Unix kernel keeps. Linux stores it in a + /// TASK_COMM_LEN buffer and macOS in a MAXCOMLEN one, both of which leave room for fifteen + /// characters and a terminator, and the truncated value is what process enumeration matches on. + /// + public const int UnixProcessNameMaxLength = 15; + /// /// How long to wait for a launcher's expected child process to appear. Measured spawn latency - /// for the Easy Anti-Cheat bootstrapper is well under two seconds. Must not exceed - /// , which bounds how old an adoptable process may be. + /// for the Easy Anti-Cheat bootstrapper is well under two seconds. Adoption dates a candidate + /// against the launcher's own start time rather than , + /// so this may be raised as far as a slow bootstrapper needs. /// public const int SpawnedChildDiscoveryTimeoutMs = 10_000; diff --git a/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs index 6fadad989..885befdeb 100644 --- a/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs +++ b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs @@ -13,30 +13,105 @@ namespace GenHub.Core.Helpers; public static class GameProcessSelector { /// - /// Selects the process matching that this launch spawned. + /// Gets the name to enumerate by when looking for . Unix kernels + /// keep only the first characters of a + /// process name, and matches + /// against that truncated value, so asking for a longer name finds nothing at all. Windows + /// reports names in full and is asked for them unchanged. + /// + /// The expected process name, without extension. + /// The name to ask the operating system for. + public static string GetDiscoveryName(string processName) + { + if (OperatingSystem.IsWindows() || processName.Length <= ProcessConstants.UnixProcessNameMaxLength) + { + return processName; + } + + return processName[..ProcessConstants.UnixProcessNameMaxLength]; + } + + /// + /// Selects the process matching that this launch spawned, with + /// no launcher of ours to date the launch by — the storefront started the game itself. A + /// recency window is all that separates the new process from an instance of the same game that + /// was already running, so it is this path's only bound on age. /// /// The processes currently observed on the machine. Each candidate's must be a UTC with . /// The expected process name, without extension. /// The directory the game must run from, or to skip the check. /// The current time, used to apply the recency window. Must be a UTC with . - /// The start time of the launcher process, if known. Must be a UTC with when supplied. /// The selected candidate, or when none qualifies. public static GameProcessCandidate? SelectSpawnedGameProcess( IEnumerable candidates, string processName, string? workingDirectory, - DateTime now, - DateTime? launcherStartTime = null) + DateTime now) { - var matches = candidates - .Where(candidate => candidate.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) - .Where(candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds); + return Select( + candidates, + processName, + workingDirectory, + candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds); + } - if (launcherStartTime.HasValue) + /// + /// Selects the process a launcher spawned, to be tracked and eventually terminated in the + /// launcher's place. Unlike this refuses to answer at all + /// when the launcher's start time is unknown: without it, a process that started before this + /// launch and merely shares the name and the workspace cannot be told apart from the child, and + /// adopting it means killing somebody else's game when this launch is stopped. + /// + /// That start time also replaces the recency window rather than joining it. It dates this + /// launch exactly, so anything at or after it started during the launch however long discovery + /// took, while a window measured against the clock expires a child that is genuinely ours the + /// moment the launcher is slow to produce it — and the discovery timeout the caller polls with + /// is configurable well past any fixed window. Keeping both would only turn a legitimate slow + /// adoption into an abandoned game that is still running. + /// + /// + /// The processes currently observed on the machine. Each candidate's must be a UTC with . + /// The expected process name, without extension. + /// The directory the game must run from, or to skip the check. + /// The start time of the launcher process. Must be a UTC with when supplied. + /// The candidate to adopt, or when none qualifies or the launcher's start time is unknown. + public static GameProcessCandidate? SelectAdoptableGameProcess( + IEnumerable candidates, + string processName, + string? workingDirectory, + DateTime? launcherStartTime) + { + if (!launcherStartTime.HasValue) { - matches = matches.Where(candidate => candidate.StartTime >= launcherStartTime.Value); + return null; } + return Select( + candidates, + processName, + workingDirectory, + candidate => candidate.StartTime >= launcherStartTime.Value); + } + + /// + /// Applies the checks both paths share and lets the caller supply the one that decides whether + /// a candidate belongs to this launch. + /// + /// The processes currently observed on the machine. + /// The expected process name, without extension. + /// The directory the game must run from, or to skip the check. + /// The caller's test for a candidate having started as part of this launch. + /// The selected candidate, or when none qualifies. + private static GameProcessCandidate? Select( + IEnumerable candidates, + string processName, + string? workingDirectory, + Func startedWithThisLaunch) + { + var matches = candidates + .Where(candidate => NameMatches(candidate, processName)) + .Where(startedWithThisLaunch); + // Residence is required whenever a working directory is known, including for a lone match: // a same-named process elsewhere on the machine is somebody else's. if (!string.IsNullOrEmpty(workingDirectory)) @@ -49,6 +124,44 @@ public static class GameProcessSelector .FirstOrDefault(); } + /// + /// Decides whether a candidate is the client the caller asked for. The image path is the + /// authority when it is readable: a Unix kernel truncates the reported process name, so the + /// path is the only place the full name survives for a client such as GeneralsOnlineZH_60. + /// The reported name is the fallback for a process whose image path cannot be read. + /// + /// The candidate to test. + /// The expected process name, without extension. + /// when the candidate carries the expected name. + private static bool NameMatches(GameProcessCandidate candidate, string processName) + { + var imageName = candidate.ExecutablePath is null ? null : Path.GetFileName(candidate.ExecutablePath); + + if (!string.IsNullOrEmpty(imageName)) + { + // A Unix binary carries no extension and may legitimately contain dots, so both + // spellings of the file name have to be offered before the candidate is rejected. + return imageName.Equals(processName, StringComparison.OrdinalIgnoreCase) + || Path.GetFileNameWithoutExtension(imageName).Equals(processName, StringComparison.OrdinalIgnoreCase); + } + + return candidate.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase) + || candidate.ProcessName.Equals(GetDiscoveryName(processName), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Decides whether a candidate runs from the expected directory. The image path is fully + /// symlink-resolved by the operating system while a configured working directory is not, so a + /// plain string comparison misses a workspace reached through a link — the /var against + /// /private/var spelling on macOS being the everyday case. Canonicalizing through the + /// filesystem also settles case: the on-disk spelling of every component is recovered under the + /// platform's own matching rules, so accepts a + /// differently cased path on a case-insensitive volume and still keeps two directories that + /// differ only in case apart on a case-sensitive one. + /// + /// The candidate to test. + /// The directory the game must run from. + /// when the candidate runs from that directory. private static bool ResidesIn(GameProcessCandidate candidate, string workingDirectory) { if (candidate.ExecutablePath is null) @@ -57,7 +170,134 @@ private static bool ResidesIn(GameProcessCandidate candidate, string workingDire } var directory = Path.GetDirectoryName(candidate.ExecutablePath); - return directory != null && Normalize(directory).Equals(Normalize(workingDirectory), StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(directory)) + { + return false; + } + + var candidateDirectory = Normalize(directory); + var expectedDirectory = Normalize(workingDirectory); + + if (candidateDirectory.Equals(expectedDirectory, PathHelper.PathComparison)) + { + return true; + } + + return Normalize(Canonicalize(candidateDirectory)) + .Equals(Normalize(Canonicalize(expectedDirectory)), PathHelper.PathComparison); + } + + /// + /// Rewrites a path so every component carries its real on-disk name and no component is a + /// symbolic link. A component that cannot be inspected is left exactly as it was spelled, so a + /// missing or malformed path degrades to the plain comparison instead of aborting the scan. + /// + /// The path to canonicalize. + /// The canonicalized path. + private static string Canonicalize(string path) => Canonicalize(path, depth: 0); + + private static string Canonicalize(string path, int depth) + { + var full = TryGetFullPath(path); + if (full is null) + { + return path; + } + + var resolved = Path.GetPathRoot(full); + if (string.IsNullOrEmpty(resolved)) + { + return path; + } + + var segments = full[resolved.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in segments) + { + resolved = ResolveSegment(resolved, segment, depth); + } + + return resolved; + } + + private static string ResolveSegment(string parent, string segment, int depth) + { + var combined = Path.Combine(parent, OnDiskName(parent, segment)); + if (depth >= IoConstants.MaxSymbolicLinkResolutionDepth) + { + return combined; + } + + var target = TryResolveLinkTarget(combined); + + // A link target is spelled by whoever created the link, so it may be reached through + // links of its own and has to go back through the same walk. + return target is null ? combined : Canonicalize(target, depth + 1); + } + + private static string? TryResolveLinkTarget(string path) + { + try + { + return Directory.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName; + } + catch (IOException) + { + // An unreadable or missing component leaves the caller's spelling in place. + } + catch (UnauthorizedAccessException) + { + // An unreadable or missing component leaves the caller's spelling in place. + } + catch (ArgumentException) + { + // A malformed component leaves the caller's spelling in place. + } + + return null; + } + + /// + /// Recovers the spelling a directory entry actually has on disk. Enumeration matches under the + /// platform's own case rules, so this changes nothing on a case-sensitive volume and folds case + /// on a volume that does. + /// + /// The directory to look in. + /// The name as it was spelled by the caller. + /// The on-disk name, or when it cannot be established. + private static string OnDiskName(string parent, string segment) + { + try + { + var entries = Directory.GetFileSystemEntries(parent, segment); + if (entries.Length == 1) + { + var onDisk = Path.GetFileName(entries[0]); + + // A name is also a search pattern, so an entry matched through a wildcard has to be + // rejected rather than substituted for a name that was never on disk. + if (onDisk.Equals(segment, StringComparison.OrdinalIgnoreCase)) + { + return onDisk; + } + } + } + catch (IOException) + { + // An unreadable directory leaves the caller's spelling in place. + } + catch (UnauthorizedAccessException) + { + // An unreadable directory leaves the caller's spelling in place. + } + catch (ArgumentException) + { + // A malformed name leaves the caller's spelling in place. + } + + return segment; } private static string Normalize(string path) @@ -65,9 +305,17 @@ private static string Normalize(string path) // MainModule.FileName is always absolute and fully resolved, while the configured working // directory is neither guaranteed. Canonicalize first so a relative spelling or a "." // segment does not read as a different directory and abandon an adoptable process. + return (TryGetFullPath(path) ?? path) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/') + .TrimEnd('/'); + } + + private static string? TryGetFullPath(string path) + { try { - path = Path.GetFullPath(path); + return Path.GetFullPath(path); } catch (ArgumentException) { @@ -82,9 +330,6 @@ private static string Normalize(string path) // A malformed path compares on its original spelling rather than aborting the scan. } - return path - .Replace(Path.DirectorySeparatorChar, '/') - .Replace(Path.AltDirectorySeparatorChar, '/') - .TrimEnd('/'); + return null; } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index 23aea4a92..b41d665af 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -84,8 +84,9 @@ public async Task StartProcessAsync_WithExpectedChild_TracksTheChildWhileTheLaun { if (!OperatingSystem.IsWindows()) { - // Process.GetProcessesByName does not enumerate these processes on macOS, so adoption - // cannot be observed there. The behaviour is Windows-only in practice. + // The hosted macOS runners do not start the harness child within the discovery + // timeout, so this asserts nothing there. Adoption itself is covered on Unix by + // StartProcessAsync_WhenAnUndeclaredLauncherForksAndExits_AdoptsTheSpawnedGameAsync. return; } @@ -199,6 +200,54 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_Reports Assert.Contains(complaint, errors); } + /// + /// A launcher that forks the game and exits 0 without declaring a child — a Wine or Proton + /// wrapper, or a stub — must have its game adopted instead of being reported as an immediate + /// exit. Adoption was gated to Windows, so these launches failed on Unix while the game ran. + /// Windows cannot exercise this path with a script launcher: a .bat is handled as a batch file + /// and skips immediate-exit handling entirely. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenAnUndeclaredLauncherForksAndExits_AdoptsTheSpawnedGameAsync() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var harness = LauncherHarness.Create(exitImmediately: true, launcherSharesChildName: true); + + if (!harness.ChildBinaryRuns) + { + // The platform refuses the copied system binary, so no child can exist to adopt and + // the assertions below would be measuring the fixture rather than the manager. + return; + } + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var result = await _processManager.StartProcessAsync(config); + + try + { + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.Equal(LauncherHarness.ChildProcessName, result.Data!.ProcessName); + Assert.True(result.Data.IsRunning); + } + finally + { + if (result.Success && result.Data is not null) + { + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + } + } + /// /// A cancelled adoption must surface as cancellation rather than a generic start failure. /// Swallowing it disagrees with TerminateProcessAsync, which rethrows, and prevents @@ -370,10 +419,14 @@ private sealed class LauncherHarness : IDisposable /// File the launcher writes its own PID into, so Dispose can stop it. private const string LauncherPidFileName = "launcher.pid"; - private LauncherHarness(string workingDirectory, string launcherPath) + /// How long to wait for the one-shot checks that prepare and vet the child. + private const int ChildProbeTimeoutMs = 5000; + + private LauncherHarness(string workingDirectory, string launcherPath, bool childBinaryRuns) { WorkingDirectory = workingDirectory; LauncherPath = launcherPath; + ChildBinaryRuns = childBinaryRuns; } /// Gets the directory the launcher and child run from. @@ -382,15 +435,24 @@ private LauncherHarness(string workingDirectory, string launcherPath) /// Gets the path of the launcher to start. public string LauncherPath { get; } + /// Gets a value indicating whether the copied child binary runs on this machine. + public bool ChildBinaryRuns { get; } + /// Creates a harness, optionally spawning a child. /// Whether the launcher should spawn the child. /// Whether the launcher should exit cleanly instead of staying alive. /// A line the launcher writes to stderr before doing anything else. + /// Whether the launcher takes the child's name, as an undeclared child is looked up by the launcher's own name. Unix only. /// The created harness. - public static LauncherHarness Create(bool spawnChild = true, bool exitImmediately = false, string? stderrMessage = null) + public static LauncherHarness Create( + bool spawnChild = true, + bool exitImmediately = false, + string? stderrMessage = null, + bool launcherSharesChildName = false) { var workingDirectory = Path.Combine(Path.GetTempPath(), "genhub-launcher-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workingDirectory); + workingDirectory = Canonicalize(workingDirectory); var childPath = Path.Combine(workingDirectory, OperatingSystem.IsWindows() ? ChildProcessName + ".exe" : ChildProcessName); File.Copy(LongRunningSystemBinary(), childPath); @@ -415,7 +477,9 @@ public static LauncherHarness Create(bool spawnChild = true, bool exitImmediatel } else { - launcherPath = Path.Combine(workingDirectory, "genhublauncher.sh"); + launcherPath = Path.Combine( + workingDirectory, + (launcherSharesChildName ? ChildProcessName : "genhublauncher") + ".sh"); var spawn = spawnChild ? $"\"{childPath}\" {LauncherLifetimeSeconds} &\n" : string.Empty; var linger = exitImmediately ? string.Empty : $"sleep {LauncherLifetimeSeconds}\n"; var complain = stderrMessage is null ? string.Empty : $"echo \"{stderrMessage}\" >&2\n"; @@ -427,8 +491,9 @@ public static LauncherHarness Create(bool spawnChild = true, bool exitImmediatel File.WriteAllText(launcherPath, script); MakeExecutable(launcherPath); MakeExecutable(childPath); + SignForLocalExecution(childPath); - return new LauncherHarness(workingDirectory, launcherPath); + return new LauncherHarness(workingDirectory, launcherPath, CanExecute(childPath)); } /// @@ -481,6 +546,82 @@ private static string LongRunningSystemBinary() return File.Exists("/bin/sleep") ? "/bin/sleep" : "/usr/bin/sleep"; } + /// + /// Resolves symlinked components so the configured working directory is spelled the way a + /// process image path is. The temp root is reached through a symlink on macOS, while a real + /// workspace is not, and selection compares the two spellings without resolving either. + /// + /// An existing directory path. + /// The path with every symlinked component replaced by its target. + private static string Canonicalize(string path) + { + var resolved = Path.GetPathRoot(path) ?? string.Empty; + + foreach (var segment in path[resolved.Length..].Split( + Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + resolved = Path.Combine(resolved, segment); + resolved = Directory.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName ?? resolved; + } + + return resolved; + } + + /// + /// Re-signs the copied system binary so the platform will run it. macOS kills a copy of a + /// platform binary on sight, and an ad-hoc signature is what makes the copy executable. + /// + private static void SignForLocalExecution(string path) + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + try + { + using var codesign = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = "codesign", + ArgumentList = { "--force", "--sign", "-", path }, + RedirectStandardError = true, + }); + codesign?.WaitForExit(ChildProbeTimeoutMs); + } + catch + { + // Best effort - CanExecute is what decides whether the child is usable. + } + } + + /// + /// Confirms the copied child really runs here, so a platform that refuses it reads as an + /// unusable fixture rather than as a launch that failed to adopt. + /// + private static bool CanExecute(string childPath) + { + if (OperatingSystem.IsWindows()) + { + return true; + } + + try + { + using var probe = System.Diagnostics.Process.Start(childPath, "0"); + if (probe is null) + { + return false; + } + + return probe.WaitForExit(ChildProbeTimeoutMs) && probe.ExitCode == 0; + } + catch + { + return false; + } + } + private static void MakeExecutable(string path) { if (OperatingSystem.IsWindows()) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs index e39b4e76e..7c68bf37c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameProcessSelectorTests.cs @@ -9,8 +9,14 @@ namespace GenHub.Tests.Core.Helpers; /// public class GameProcessSelectorTests { + /// A real client whose name is longer than a Unix kernel will report. + private const string LongClientName = "GeneralsOnlineZH_60"; + private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); + /// The name a Unix kernel reports for . + private static readonly string TruncatedClientName = LongClientName[..ProcessConstants.UnixProcessNameMaxLength]; + // Native separators on both platforms: a real workspace path never mixes them, and comparing // like-for-like is what the non-separator tests are meant to exercise. private static readonly string Workspace = Path.Combine(Path.GetTempPath(), "genhub-workspace", "generalsonline"); @@ -156,6 +162,261 @@ public void SelectSpawnedGameProcess_WithNoNameMatch_ReturnsNull() Assert.Null(selected); } + /// + /// A Unix kernel keeps only characters + /// of a process name, so every client whose name is longer — which is most of the ones this + /// adoption path exists for — reports a truncated name and the full one survives only in the + /// image path. Matching on the reported name alone finds none of them. + /// + [Fact] + public void SelectSpawnedGameProcess_MatchesACandidateWhoseKernelTruncatedItsName() + { + var candidates = new[] + { + new GameProcessCandidate(1, TruncatedClientName, Now, Path.Combine(Workspace, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, Workspace, Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// Two clients that share a truncated name are still different clients, and the image path is + /// what tells them apart. Matching on the truncated name alone would adopt either one. + /// + [Fact] + public void SelectSpawnedGameProcess_RejectsATruncatedNameBelongingToADifferentClient() + { + var otherClient = TruncatedClientName + "H_61"; + var candidates = new[] + { + new GameProcessCandidate(1, TruncatedClientName, Now, Path.Combine(Workspace, otherClient)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, Workspace, Now); + + Assert.Null(selected); + } + + /// + /// With no image path to read, the truncated name the kernel reports is the only evidence + /// there is, so it has to be accepted where the kernel truncates and nowhere else. + /// + [Fact] + public void SelectSpawnedGameProcess_WithoutAnImagePath_FallsBackToTheTruncatedProcessName() + { + var candidates = new[] { new GameProcessCandidate(1, TruncatedClientName, Now, null) }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess(candidates, LongClientName, null, Now); + + Assert.Equal(!OperatingSystem.IsWindows(), selected is not null); + } + + /// + /// Enumeration matches against the name the kernel kept, so a longer name has to be shortened + /// to the same prefix before it is asked for. Windows reports names in full. + /// + [Fact] + public void GetDiscoveryName_ShortensNamesTheUnixKernelWouldTruncate() + { + var discoveryName = GameProcessSelector.GetDiscoveryName(LongClientName); + + Assert.Equal(OperatingSystem.IsWindows() ? LongClientName : TruncatedClientName, discoveryName); + } + + /// + /// A name the kernel keeps whole is asked for exactly as it is on every platform. + /// + [Fact] + public void GetDiscoveryName_LeavesNamesTheKernelKeepsWhole() + { + Assert.Equal("generalszh", GameProcessSelector.GetDiscoveryName("generalszh")); + } + + /// + /// The operating system reports a fully symlink-resolved image path while a configured working + /// directory keeps whatever spelling it was given, so residence has to be decided against the + /// real directory rather than the two spellings of it. + /// + [Fact] + public void SelectSpawnedGameProcess_MatchesAWorkingDirectoryReachedThroughASymlink() + { + var root = CreateTempRoot(); + try + { + var real = Path.Combine(root, "real", "workspace"); + Directory.CreateDirectory(real); + + var link = Path.Combine(root, "link"); + if (!TryCreateDirectorySymbolicLink(link, Path.Combine(root, "real"))) + { + // The platform will not let this account create links, so there is nothing to test. + return; + } + + var candidates = new[] + { + new GameProcessCandidate(1, LongClientName, Now, Path.Combine(real, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, LongClientName, Path.Combine(link, "workspace"), Now); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Residence follows the volume rather than a fixed string rule: a case-insensitive volume — + /// the macOS and Windows default — must not reject a differently cased spelling of the very + /// directory the game runs from, and a case-sensitive one must keep two such directories apart. + /// + [Fact] + public void SelectSpawnedGameProcess_FollowsTheVolumeCaseRulesWhenComparingResidence() + { + var root = CreateTempRoot(); + try + { + var onDisk = Path.Combine(root, "Workspace"); + Directory.CreateDirectory(onDisk); + + var lowerCased = Path.Combine(root, "workspace"); + var candidates = new[] + { + new GameProcessCandidate(1, LongClientName, Now, Path.Combine(onDisk, LongClientName)), + }; + + var selected = GameProcessSelector.SelectSpawnedGameProcess( + candidates, LongClientName, lowerCased, Now); + + Assert.Equal(Directory.Exists(lowerCased), selected is not null); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// A launcher whose start time cannot be read leaves nothing to separate the child it spawned + /// from an instance of the same game already running in the same workspace, so adoption is + /// declined outright rather than gambling on the recency window. + /// + [Fact] + public void SelectAdoptableGameProcess_WithoutALauncherStartTime_AdoptsNothing() + { + var candidates = new[] { Candidate(1, LongClientName, Now, Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime: null); + + Assert.Null(selected); + } + + /// + /// A known launcher start time disqualifies anything that was already running when the + /// launcher started, however recently it started. + /// + [Fact] + public void SelectAdoptableGameProcess_RejectsACandidateThatPredatesTheLauncher() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime.AddSeconds(-1), Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.Null(selected); + } + + /// + /// The process the launcher started is the one adoption is for. + /// + [Fact] + public void SelectAdoptableGameProcess_AdoptsTheChildStartedAfterTheLauncher() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] + { + Candidate(1, LongClientName, launcherStartTime.AddSeconds(-1), Workspace), + Candidate(2, LongClientName, launcherStartTime.AddSeconds(1), Workspace), + }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(2, selected.ProcessId); + } + + /// + /// A child can be recorded as starting in the same clock tick as the launcher that spawned it, + /// so the launcher's own start time has to qualify rather than disqualify. + /// + [Fact] + public void SelectAdoptableGameProcess_AcceptsACandidateStartedAtTheLauncherStartTime() + { + var launcherStartTime = Now.AddSeconds(-2); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime, Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + + /// + /// A launcher may take longer than to + /// make its child enumerable, and the discovery timeout the caller polls with is configurable + /// well past that. The child still started with this launch, so it must be adopted rather than + /// left running with nothing tracking it. Anchored to the real clock: the adoption path takes + /// no time of its own, so any recency window reintroduced here would have to read that clock. + /// + [Fact] + public void SelectAdoptableGameProcess_AdoptsAChildOlderThanTheRecencyWindow() + { + var launcherStartTime = DateTime.UtcNow.AddSeconds(-(ProcessConstants.EarlyExitThresholdSeconds + 20)); + var candidates = new[] { Candidate(1, LongClientName, launcherStartTime.AddSeconds(1), Workspace) }; + + var selected = GameProcessSelector.SelectAdoptableGameProcess( + candidates, LongClientName, Workspace, launcherStartTime); + + Assert.NotNull(selected); + Assert.Equal(1, selected.ProcessId); + } + private static GameProcessCandidate Candidate(int id, string name, DateTime startTime, string directory) => new(id, name, startTime, Path.Combine(directory, name + ".exe")); + + private static string CreateTempRoot() + { + var root = Path.Combine(Path.GetTempPath(), "genhub-selector-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static bool TryCreateDirectorySymbolicLink(string path, string target) + { + try + { + Directory.CreateSymbolicLink(path, target); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } } diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 4fddc56f1..933b635d9 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -84,11 +84,16 @@ public async Task> StartProcessAsync(GameLaunch process = startResult.Data; logger.LogDebug("[Process] Process {ProcessId} started successfully", process.Id); + // Read while the launcher is still alive: a Unix process that has exited can no longer + // report its start time, and that time is the only thing separating the child this + // launch spawned from an instance of the same game the user already had running. + var launcherStartTime = ReadStartTime(process); + var capturedErrors = SetupErrorRedirection(process); if (!string.IsNullOrWhiteSpace(configuration.ExpectedChildProcessName)) { - return await AdoptExpectedChildProcessAsync(process, configuration, workingDirectory, capturedErrors, cancellationToken); + return await AdoptExpectedChildProcessAsync(process, configuration, workingDirectory, launcherStartTime, capturedErrors, cancellationToken); } if (!isBatchFile) @@ -97,7 +102,7 @@ public async Task> StartProcessAsync(GameLaunch if (process.HasExited) { - return HandleImmediateProcessExit(process, configuration, capturedErrors); + return HandleImmediateProcessExit(process, configuration, launcherStartTime, capturedErrors); } } @@ -568,6 +573,24 @@ private static bool HasExecutePermission(string path) } } + /// + /// Reads a process's start time in UTC, or reports that it could not be read. + /// + /// The process to inspect. + /// The start time, or when the platform will not report it. + private DateTime? ReadStartTime(Process process) + { + try + { + return process.StartTime.ToUniversalTime(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "[Process] Unable to inspect start time for process {ProcessId}", process.Id); + return null; + } + } + private OperationResult ValidateLaunchConfiguration(GameLaunchConfiguration? configuration) { if (configuration == null) @@ -770,11 +793,16 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi private OperationResult HandleImmediateProcessExit( Process process, GameLaunchConfiguration configuration, + DateTime? launcherStartTime, BoundedErrorBuffer capturedErrors) { var exitCode = process.ExitCode; - if (exitCode == 0 && OperatingSystem.IsWindows()) + // Adoption is not gated on Windows: a Wine or Proton wrapper forks and exits the same way, + // and adoption only accepts a candidate that carries the name, started at or after this + // launcher, is inside the recency window, and runs from the workspace directory. If the + // engine really did exit, nothing satisfies that and the launch still fails loudly. + if (exitCode == ProcessConstants.ExitCodeSuccess) { logger.LogInformation( "[Process] Launcher process {ProcessId} exited with code 0 - attempting to find spawned game process", @@ -784,17 +812,7 @@ private OperationResult HandleImmediateProcessExit( ? configuration.ExpectedChildProcessName : Path.GetFileNameWithoutExtension(configuration.ExecutablePath); - DateTime? launcherStartTime = null; - try - { - launcherStartTime = process.StartTime.ToUniversalTime(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "[Process] Unable to inspect start time for exiting launcher {ProcessId}", process.Id); - } - - var spawnedProcess = FindSpawnedGameProcess( + var spawnedProcess = FindAdoptableGameProcess( executableName, configuration.WorkingDirectory ?? Path.GetDirectoryName(configuration.ExecutablePath)!, launcherStartTime); @@ -899,6 +917,7 @@ private void OnProcessExited(object? sender, EventArgs e) /// The process that was started. /// The launch configuration. /// The directory the game must run from. + /// The launcher's start time, read while it was still running. /// /// The launcher's captured stderr, quoted in the failure messages so a bootstrapper /// that refuses to start the game can say why. @@ -909,6 +928,7 @@ private async Task> AdoptExpectedChildProcessAs Process launcher, GameLaunchConfiguration configuration, string workingDirectory, + DateTime? launcherStartTime, BoundedErrorBuffer capturedErrors, CancellationToken cancellationToken) { @@ -919,27 +939,37 @@ private async Task> AdoptExpectedChildProcessAs var gracePeriod = TimeSpan.FromMilliseconds(ProcessConstants.LauncherExitGracePeriodMs); DateTime? launcherExitedAt = null; - logger.LogInformation( - "[Process] Waiting up to {TimeoutMs}ms for launcher {LauncherId} to start {ExpectedName}", - (int)timeout.TotalMilliseconds, - launcher.Id, - expectedName); - - DateTime? launcherStartTime = null; try { - launcherStartTime = launcher.StartTime.ToUniversalTime(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "[Process] Unable to inspect start time for launcher {ProcessId}", launcher.Id); - } + // Adoption requires the launcher's start time to rule out an instance of the game the + // user already had running, so without it no candidate can ever qualify. Polling that + // out would repeat the refusal once per interval and then report a discovery timeout, + // which describes a launcher that was never given the chance to fail. + if (!launcherStartTime.HasValue) + { + logger.LogError( + "[Process] Not waiting for {ExpectedName}: the launcher's start time is unknown, so a process that predates this launch cannot be ruled out", + expectedName); + + await TerminateAbandonedLauncherAsync(launcher); + + // Terminated first, so the launcher has exited and its stderr drains in full. + return OperationResult.CreateFailure( + AppendLauncherErrors( + $"Cannot adopt {expectedName}: the launcher's start time could not be read.", + launcher, + capturedErrors)); + } + + logger.LogInformation( + "[Process] Waiting up to {TimeoutMs}ms for launcher {LauncherId} to start {ExpectedName}", + (int)timeout.TotalMilliseconds, + launcher.Id, + expectedName); - try - { while (true) { - var child = FindSpawnedGameProcess(expectedName, workingDirectory, launcherStartTime); + var child = FindAdoptableGameProcess(expectedName, workingDirectory, launcherStartTime); if (child != null) { _managedProcesses[child.Id] = child; @@ -1122,19 +1152,54 @@ private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecuta } /// - /// Finds a spawned game process by executable name and working directory. - /// Used when a launcher executable spawns the actual game and exits. + /// Finds a game process by executable name and working directory, without a launcher to bound + /// the search. Used when discovering a game a storefront started on our behalf. + /// + /// The base executable name without extension. + /// The expected working directory. + /// The discovered process if found, null otherwise. + private Process? FindSpawnedGameProcess(string executableName, string workingDirectory) => + FindGameProcess( + executableName, + candidates => GameProcessSelector.SelectSpawnedGameProcess( + candidates, executableName, workingDirectory, DateTime.UtcNow)); + + /// + /// Finds the process a launcher spawned, to be tracked and terminated in the launcher's place. /// /// The base executable name without extension. /// The expected working directory. /// The start time of the launcher process, if known. - /// The spawned process if found, null otherwise. - private Process? FindSpawnedGameProcess(string executableName, string workingDirectory, DateTime? launcherStartTime = null) + /// The process to adopt if one qualifies, null otherwise. + private Process? FindAdoptableGameProcess(string executableName, string workingDirectory, DateTime? launcherStartTime) + { + if (!launcherStartTime.HasValue) + { + logger.LogWarning( + "[Process] Not adopting a running {ExecutableName}: the launcher's start time is unknown, so a process that predates this launch cannot be ruled out", + executableName); + return null; + } + + return FindGameProcess( + executableName, + candidates => GameProcessSelector.SelectAdoptableGameProcess( + candidates, executableName, workingDirectory, launcherStartTime.Value.ToUniversalTime())); + } + + /// + /// Enumerates the processes that could carry and hands them + /// to a selection policy. + /// + /// The base executable name without extension. + /// The policy deciding which candidate, if any, is ours. + /// The selected process if found, null otherwise. + private Process? FindGameProcess(string executableName, Func, GameProcessCandidate?> select) { Process[] processes = []; try { - processes = Process.GetProcessesByName(executableName); + processes = Process.GetProcessesByName(GameProcessSelector.GetDiscoveryName(executableName)); } catch (Exception ex) { @@ -1163,8 +1228,7 @@ private GameProcessInfo BuildProcessInfo(Process process, string fallbackExecuta } } - var selected = GameProcessSelector.SelectSpawnedGameProcess( - candidates, executableName, workingDirectory, DateTime.UtcNow, launcherStartTime?.ToUniversalTime()); + var selected = select(candidates); if (selected == null) { From b13cd0474c68b60e3ebfe53a19958e41f1ef7f8b Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:17:09 -0400 Subject: [PATCH 08/20] fix(userdata): copy CAS content to user-writable targets and guard destructive data deletion (#383) * fix(userdata): copy CAS content to user-writable targets and guard destructive data deletion * test(userdata): narrow the hard-link helper catch to expected failures * fix(workspace): open the copy source before unlinking the destination * fix(userdata): distinguish a failed hash check from a real mismatch before moving files aside * fix(userdata): restore uninstall backups by copy so a redirected Documents folder cannot strand them * fix(userdata): fail the uninstall and keep tracking data when a pristine backup cannot be restored * fix(userdata): abort delete-all on cancellation before any tracking metadata is removed * fix(userdata): let delete-all finish when an index key has no manifest left to restore * fix(settings): report a failed delete-all confirmation instead of letting it escape the command * test(settings): assert every deletion channel is gated on the delete-all confirmation * fix(userdata): consume the backup when rollback or deactivation restores the original * fix(userdata): report delete-all as a failure naming the backups it had to keep * fix(userdata): default an unmapped install target to copying instead of hard-linking * fix(userdata): surface backups left behind when a failed install is rolled back * docs(constants): document the delete-all prompt constants and UserDataConstants * fix(userdata): surface the uninstall failure that profile cleanup used to discard * fix(userdata): let a cancelled manifest read abort instead of reading as corruption * fix(userdata): keep a consumed backup's failed delete from failing the restore * fix(userdata): keep the tracking data that maps retained backups to their paths * fix(workspace): report a missing file as an unverified hash rather than a mismatch * fix(workspace): resolve links before deciding a copy would overwrite its own source * fix(settings): stop the delete-all summary contradicting a partial user data failure * fix(workspace): replace a destination link to the source instead of skipping the copy * test(userdata): prove the tightened backup directory really denies a delete before relying on it --- GenHub/GenHub.Core/Constants/AppConstants.cs | 19 + .../Constants/UserDataConstants.cs | 13 + .../Enums/ContentInstallTargetExtensions.cs | 29 + .../Workspace/IFileOperationsService.cs | 16 + .../Models/Enums/FileHashVerification.cs | 23 + .../ContentInstallTargetExtensionsTests.cs | 54 ++ .../ViewModels/MainViewModelTests.cs | 2 + .../ViewModels/SettingsViewModelTests.cs | 238 ++++- .../UserDataTrackerServiceSafetyTests.cs | 811 ++++++++++++++++++ .../UserData/UserDataTrackerServiceTests.cs | 26 + .../Workspace/FileOperationsServiceTests.cs | 128 +++ .../Workspace/TestFileOperationsService.cs | 4 + .../Workspace/WindowsFileOperationsService.cs | 4 + .../Settings/ViewModels/SettingsViewModel.cs | 95 +- .../Services/ProfileContentLinkerService.cs | 29 +- .../Services/UserDataTrackerService.cs | 424 +++++++-- .../Workspace/FileOperationsService.cs | 156 +++- .../Workspace/UnixFileOperationsService.cs | 4 + .../SharedViewModelModule.cs | 1 + docs/dev/constants.md | 15 + 20 files changed, 1966 insertions(+), 125 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/UserDataConstants.cs create mode 100644 GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs diff --git a/GenHub/GenHub.Core/Constants/AppConstants.cs b/GenHub/GenHub.Core/Constants/AppConstants.cs index 260c52b6f..17d2c9335 100644 --- a/GenHub/GenHub.Core/Constants/AppConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppConstants.cs @@ -132,6 +132,25 @@ public static string FullDisplayVersion /// public const string TokenFileName = ".ghtoken"; + /// + /// Title of the confirmation prompt shown before all application data is deleted. + /// + public const string DeleteAllDataConfirmationTitle = "Delete All Application Data"; + + /// + /// Body of the confirmation prompt shown before all application data is deleted. + /// + public const string DeleteAllDataConfirmationMessage = + "This permanently deletes every profile, workspace, manifest, CAS object and tracked user data " + + "installation. The pristine backups GenHub keeps of your original game data will be discarded " + + "as part of this, so anything GenHub replaced cannot be recovered afterwards.\n\n" + + "This action is irreversible. Continue?"; + + /// + /// Confirm button text for the delete-all-application-data prompt. + /// + public const string DeleteAllDataConfirmText = "Delete Everything"; + /// /// Gets assembly metadata by key. /// diff --git a/GenHub/GenHub.Core/Constants/UserDataConstants.cs b/GenHub/GenHub.Core/Constants/UserDataConstants.cs new file mode 100644 index 000000000..8de21fae4 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/UserDataConstants.cs @@ -0,0 +1,13 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for tracked user data installations. +/// +public static class UserDataConstants +{ + /// + /// Suffix appended to a deployed file that no longer matches its recorded hash when it is + /// moved aside so the pristine backup can be restored over it. + /// + public const string UserModifiedSuffix = ".user-modified"; +} diff --git a/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs new file mode 100644 index 000000000..68b199060 --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs @@ -0,0 +1,29 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Extensions.Enums; + +/// +/// Provides extension methods for the enum. +/// +public static class ContentInstallTargetExtensions +{ + /// + /// Determines whether the target resolves to a directory the user and the game engine + /// write to directly, which means deployed content must never share storage with the + /// content-addressable object it originated from. + /// + /// Only the two targets that are definitively not user data are listed as such: every other + /// value, including any added later, is treated as user-writable and therefore copied. That + /// matches the resolver, whose own default arm places unmapped targets inside the user data + /// root, and it fails towards an extra copy rather than towards a hard link into Documents. + /// + /// + /// The install target to inspect. + /// true when the destination is user-writable; otherwise, false. + public static bool IsUserWritableTarget(this ContentInstallTarget installTarget) => installTarget switch + { + ContentInstallTarget.Workspace => false, + ContentInstallTarget.System => false, + _ => true, + }; +} diff --git a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs index 6038b2efe..58b76818a 100644 --- a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs +++ b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs @@ -58,6 +58,22 @@ Task VerifyFileHashAsync( string expectedHash, CancellationToken cancellationToken = default); + /// + /// Compares a file against an expected hash, distinguishing a genuine mismatch from a failure to + /// compute the hash at all. Callers that act destructively on a mismatch must use this rather + /// than , which collapses both outcomes into false. + /// A file that does not exist yields : no hash was + /// computed, so its absence is not evidence that its content ever differed. + /// + /// The file path. + /// The expected hash value. + /// A cancellation token. + /// The verification outcome. + Task CheckFileHashAsync( + string filePath, + string expectedHash, + CancellationToken cancellationToken = default); + /// /// Applies a patch to a target file. The patch format is determined by the implementation. /// diff --git a/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs new file mode 100644 index 000000000..1ce5e9797 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs @@ -0,0 +1,23 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Outcome of comparing a file's content against an expected hash. +/// +public enum FileHashVerification +{ + /// + /// The hash could not be computed, so nothing is known about the file's content. + /// Callers must not treat this as evidence that the file changed. + /// + Failed = 0, + + /// + /// The hash was computed and matches the expected value. + /// + Match = 1, + + /// + /// The hash was computed and differs from the expected value. + /// + Mismatch = 2, +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs new file mode 100644 index 000000000..0396848f4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using GenHub.Core.Extensions.Enums; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Extensions.Enums; + +/// +/// Tests for . +/// +public class ContentInstallTargetExtensionsTests +{ + /// + /// The four user directories must be copied out of CAS rather than hard-linked, because the game + /// engine writes into them in place and would otherwise rewrite the canonical CAS object. + /// + /// The user-writable target under test. + [Theory] + [InlineData(ContentInstallTarget.UserDataDirectory)] + [InlineData(ContentInstallTarget.UserMapsDirectory)] + [InlineData(ContentInstallTarget.UserReplaysDirectory)] + [InlineData(ContentInstallTarget.UserScreenshotsDirectory)] + public void IsUserWritableTarget_ForUserDirectories_ReturnsTrue(ContentInstallTarget installTarget) + { + Assert.True(installTarget.IsUserWritableTarget()); + } + + /// + /// Workspace and system installs are managed by GenHub rather than written to by the user, so + /// they remain eligible for hard links to CAS. + /// + /// The GenHub-managed target under test. + [Theory] + [InlineData(ContentInstallTarget.Workspace)] + [InlineData(ContentInstallTarget.System)] + public void IsUserWritableTarget_ForGenHubManagedTargets_ReturnsFalse(ContentInstallTarget installTarget) + { + Assert.False(installTarget.IsUserWritableTarget()); + } + + /// + /// An install target this method has never been taught about must fail towards copying. The path + /// resolver sends unmapped targets into the user data root, so answering "not user-writable" + /// would hard-link a CAS object straight into the user's Documents folder. + /// + [Fact] + public void IsUserWritableTarget_ForAnUnmappedTarget_FailsTowardsCopying() + { + var unmapped = Enum.GetValues().Cast().Max() + 1; + + Assert.True(((ContentInstallTarget)unmapped).IsUserWritableTarget()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index ac6b1e057..e4f86269e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -618,6 +618,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet var mockInstallationService = new Mock(); var mockStorageLocationService = new Mock(); var mockUserDataTracker = new Mock(); + var mockDialogService = new Mock(); var mockGitHubTokenStorage = new Mock(); var settingsVm = new SettingsViewModel( @@ -633,6 +634,7 @@ private static (SettingsViewModel SettingsVm, Mock UserSet mockInstallationService.Object, mockStorageLocationService.Object, mockUserDataTracker.Object, + mockDialogService.Object, mockGitHubTokenStorage.Object); return (settingsVm, mockUserSettings); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index 5a2cd3cbb..d00fe9ed4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -39,6 +39,7 @@ public class SettingsViewModelTests private readonly Mock _mockInstallationService; private readonly Mock _mockStorageLocationService; private readonly Mock _mockUserDataTracker; + private readonly Mock _mockDialogService; private readonly UserSettings _defaultSettings; /// @@ -58,9 +59,13 @@ public SettingsViewModelTests() _mockInstallationService = new Mock(); _mockStorageLocationService = new Mock(); _mockUserDataTracker = new Mock(); + _mockDialogService = new Mock(); _defaultSettings = new UserSettings(); _mockConfigService.Setup(x => x.Get()).Returns(_defaultSettings); + _mockUserDataTracker + .Setup(x => x.DeleteAllUserDataAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); } /// @@ -93,7 +98,8 @@ public void Constructor_LoadsSettingsFromUserSettingsService() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert Assert.Equal("Light", viewModel.Theme); @@ -122,7 +128,8 @@ public async Task SaveSettingsCommand_UpdatesUserSettingsServiceAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { Theme = "Light", MaxConcurrentDownloads = 5, @@ -156,7 +163,8 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { Theme = "Light", MaxConcurrentDownloads = 10, @@ -272,7 +280,8 @@ public void MaxConcurrentDownloads_SetsValueWithinBounds() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { // Act & Assert - Test lower bound MaxConcurrentDownloads = 0, @@ -307,7 +316,8 @@ public void AvailableThemes_ReturnsExpectedValues() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act var themes = SettingsViewModel.AvailableThemes.ToList(); @@ -337,7 +347,8 @@ public void AvailableWorkspaceStrategies_ReturnsAllEnumValues() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act var strategies = SettingsViewModel.AvailableWorkspaceStrategies.ToList(); @@ -369,7 +380,8 @@ public async Task SaveSettingsCommand_HandlesUserSettingsServiceExceptionAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); @@ -407,7 +419,8 @@ public void Constructor_HandlesUserSettingsServiceException() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert - Should not throw and use defaults Assert.Equal("Dark", viewModel.Theme); @@ -447,7 +460,8 @@ public async Task DeleteCasStorageCommand_ReportsGarbageCollectionIsDisabledAsyn _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await viewModel.DeleteCasStorageCommand.ExecuteAsync(null); @@ -490,7 +504,8 @@ public async Task UninstallGenHubCommand_CallsServiceAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Act await viewModel.UninstallGenHubCommand.ExecuteAsync(null); @@ -498,4 +513,207 @@ public async Task UninstallGenHubCommand_CallsServiceAsync() // Assert _mockUpdateManager.Verify(x => x.Uninstall(), Times.Once); } + + /// + /// Verifies that declining the confirmation prompt leaves every piece of application data alone. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationDeclined_DeletesNothingAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(false); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Never); + _mockCasService.Verify(x => x.RunGarbageCollectionAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockInstallationService.Verify(x => x.InvalidateCache(), Times.Never); + _mockProfileManager.Verify(x => x.DeleteProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that a confirmation prompt that fails to open — no main window, or an Avalonia + /// failure — is reported to the user instead of escaping the command unlogged, and that it still + /// deletes nothing. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationThrows_ReportsErrorAndDeletesNothingAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("no main window")); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockNotificationService.Verify( + x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Never); + _mockProfileManager.Verify(x => x.DeleteProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that accepting the confirmation prompt performs the deletion. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenConfirmationAccepted_DeletesAllDataAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockUserDataTracker.Verify(x => x.DeleteAllUserDataAsync(It.IsAny()), Times.Once); + _mockCasService.Verify(x => x.RunGarbageCollectionAsync(true, It.IsAny()), Times.Once); + _mockInstallationService.Verify(x => x.InvalidateCache(), Times.Once); + _mockProfileManager.Verify(x => x.DeleteProfileAsync("profile-to-delete", It.IsAny()), Times.Once); + _mockWorkspaceManager.Verify(x => x.CleanupWorkspaceAsync("workspace-to-delete", It.IsAny()), Times.Once); + _mockManifestPool.Verify(x => x.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verifies that a user data deletion that had to keep some data is not followed by a success + /// message claiming that data was deleted. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WhenUserDataPartiallyDeleted_DoesNotClaimSuccessAsync() + { + // Arrange + SetupDeletableData(); + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + _mockUserDataTracker + .Setup(x => x.DeleteAllUserDataAsync(It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Your originals were kept at 'backups'.")); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + _mockNotificationService.Verify( + x => x.ShowError("User Data Partially Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + _mockNotificationService.Verify( + x => x.ShowSuccess("Data Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _mockNotificationService.Verify( + x => x.ShowWarning("Data Partially Deleted", It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that the confirmation prompt states the action is irreversible and that game data + /// backups are discarded, and that it cannot be suppressed by a "do not ask again" preference. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DeleteAllDataCommand_WarnsThatBackupsAreDiscardedAndCannotBeSuppressedAsync() + { + // Arrange + string? capturedMessage = null; + string? capturedSessionKey = null; + _mockDialogService + .Setup(x => x.ShowConfirmationAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((title, message, confirmText, cancelText, sessionKey) => + { + capturedMessage = message; + capturedSessionKey = sessionKey; + }) + .ReturnsAsync(false); + + var viewModel = CreateViewModel(); + + // Act + await viewModel.DeleteAllDataCommand.ExecuteAsync(null); + + // Assert + Assert.Equal(AppConstants.DeleteAllDataConfirmationMessage, capturedMessage); + Assert.Contains("irreversible", capturedMessage!, StringComparison.OrdinalIgnoreCase); + Assert.Contains("backups", capturedMessage!, StringComparison.OrdinalIgnoreCase); + Assert.Null(capturedSessionKey); + } + + private void SetupDeletableData() + { + _mockProfileManager + .Setup(x => x.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([new GameProfile { Id = "profile-to-delete" }])); + _mockWorkspaceManager + .Setup(x => x.GetAllWorkspacesAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([new WorkspaceInfo { Id = "workspace-to-delete" }])); + _mockManifestPool + .Setup(x => x.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([new ContentManifest { Name = "manifest-to-delete" }])); + } + + private SettingsViewModel CreateViewModel() => new( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object, + _mockDialogService.Object); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs new file mode 100644 index 000000000..bfa746365 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs @@ -0,0 +1,811 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.UserData.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.UserData; + +/// +/// Tests covering the data-safety guarantees of : deployed user +/// data must be independent of the CAS object it came from, and a pristine backup must survive a +/// deployed file the user has since modified. +/// +public sealed partial class UserDataTrackerServiceSafetyTests : IDisposable +{ + private const string TestManifestId = "1.1015255.generalsonline.patch.gamedata"; + private const string TestProfileId = "profile-zh-safety"; + private const string TestVersion = "101525_QFE5"; + private const string TestManifestName = "GameData Patch"; + private const string TestRelativePath = "GeneralsOnlineGameData/splash.bmp"; + private const string TestHash = "hash-splash-safety"; + private const string CasContent = "pristine-cas-content"; + + private readonly string _tempDir; + private readonly string _appDataDir; + private readonly string _casDir; + private readonly string _zeroHourDataDir; + private readonly Mock _configProviderMock; + private readonly Mock _fileOperationsMock; + private readonly Mock> _loggerMock; + private readonly Mock _pathProviderMock; + private readonly UserDataTrackerService _trackerService; + + /// + /// Initializes a new instance of the class. + /// + public UserDataTrackerServiceSafetyTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_UserDataSafetyTests_" + Guid.NewGuid().ToString("N")); + _appDataDir = Path.Combine(_tempDir, "AppData"); + _casDir = Path.Combine(_tempDir, "Cas"); + _zeroHourDataDir = Path.Combine(_tempDir, GameSettingsConstants.FolderNames.ZeroHour); + + Directory.CreateDirectory(_appDataDir); + Directory.CreateDirectory(_casDir); + Directory.CreateDirectory(_zeroHourDataDir); + File.WriteAllText(Path.Combine(_casDir, TestHash), CasContent); + + _configProviderMock = new Mock(); + _configProviderMock.Setup(c => c.GetApplicationDataPath()).Returns(_appDataDir); + + _loggerMock = new Mock>(); + + _pathProviderMock = new Mock(); + _pathProviderMock.Setup(p => p.GetOptionsDirectory(GameType.ZeroHour)).Returns(_zeroHourDataDir); + + _fileOperationsMock = new Mock(); + + // Faithful CAS behaviour: a hard link really shares storage with the object, a copy does not. + _fileOperationsMock + .Setup(f => f.LinkFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((hash, targetPath, useHardLink, contentType, token) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + return Task.FromResult(TryCreateHardLink(Path.Combine(_casDir, hash), targetPath)); + }); + + _fileOperationsMock + .Setup(f => f.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((hash, targetPath, contentType, token) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(Path.Combine(_casDir, hash), targetPath, overwrite: true); + return Task.FromResult(true); + }); + + _fileOperationsMock + .Setup(f => f.VerifyFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(true); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + + _trackerService = new UserDataTrackerService( + _configProviderMock.Object, + _fileOperationsMock.Object, + _loggerMock.Object, + _pathProviderMock.Object); + } + + /// + /// Cleans up test resources. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Ignore test cleanup errors + } + } + + /// + /// Verifies that a file installed into the user's game data directory is an independent copy, so + /// writing to it — as the game engine and GenHub's own settings writer both do — cannot reach the + /// CAS object that every profile referencing the hash shares. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InstallUserDataAsync_UserWritableTarget_DeploysIndependentCopyAsync() + { + // Arrange + var casObjectPath = Path.Combine(_casDir, TestHash); + + // Act + var result = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + + // Assert + Assert.True(result.Success); + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Assert.True(File.Exists(deployedPath)); + Assert.Equal(CasContent, File.ReadAllText(deployedPath)); + + // The game writes into this directory in place; that must not reach the CAS object. + File.WriteAllText(deployedPath, "engine-rewrote-this-file-with-different-content"); + + Assert.Equal(CasContent, File.ReadAllText(casObjectPath)); + Assert.False(result.Data!.InstalledFiles[0].IsHardLink); + + _fileOperationsMock.Verify( + f => f.LinkFromCasAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that a deployed file the user has modified is moved aside rather than left in place, + /// so the pristine backup is still restored over the original path instead of being discarded. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_HashMismatch_PreservesModifiedFileAndRestoresBackupAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + const string modifiedContent = "the-user-edited-this"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + File.WriteAllText(deployedPath, modifiedContent); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Mismatch); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + + var preservedPath = deployedPath + UserDataConstants.UserModifiedSuffix; + Assert.True(File.Exists(preservedPath)); + Assert.Equal(modifiedContent, File.ReadAllText(preservedPath)); + } + + /// + /// A deployed file whose hash could not be computed at all — an IO error, or the running game + /// briefly holding it open — is not evidence that the user changed it. Moving it aside and + /// restoring over it would churn a pristine file and log a preserved edit that never happened, + /// so the file and its backup are both left alone. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenVerificationFails_LeavesDeployedFileUntouchedAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath; + Assert.NotNull(backupPath); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.False(uninstallResult.Success); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + Assert.Equal(CasContent, File.ReadAllText(deployedPath)); + Assert.True(File.Exists(backupPath)); + Assert.Equal(originalUserContent, File.ReadAllText(backupPath!)); + } + + /// + /// Pins the dangerous window an uninstall opens: the deployed file has already been moved aside + /// and the restore of the pristine original then fails, leaving the original path empty. The + /// uninstall must report that failure and keep its tracking data, because the manifest is the + /// only record tying a machine-named backup to the path it belongs at. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenRestoreFailsAfterMoveAside_ReportsFailureAndKeepsTrackingDataAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + + // The backup disappears in the window between the move-aside and the restore. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + File.Delete(backupPath); + return FileHashVerification.Mismatch; + }); + + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.False(uninstallResult.Success); + Assert.False(File.Exists(deployedPath)); + + var preservedPath = deployedPath + UserDataConstants.UserModifiedSuffix; + Assert.True(File.Exists(preservedPath)); + Assert.Equal(CasContent, File.ReadAllText(preservedPath)); + + var manifestsPath = Path.Combine(_appDataDir, "UserData", "manifests"); + Assert.NotEmpty(Directory.GetFiles(manifestsPath, "*", SearchOption.AllDirectories)); + } + + /// + /// Profile cleanup runs the same uninstall, so it must not report success while an original the + /// user never asked to lose is still sitting in the backups tree. Every caller above it reads + /// this result and nothing else. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CleanupProfileAsync_WhenRestoreFails_ReportsTheUnfinishedUninstallAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + + // The backup disappears in the window between the move-aside and the restore. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + File.Delete(backupPath); + return FileHashVerification.Mismatch; + }); + + // Act + var cleanupResult = await _trackerService.CleanupProfileAsync(TestProfileId, CancellationToken.None); + + // Assert + Assert.False(cleanupResult.Success); + Assert.Contains(Path.Combine(_appDataDir, "UserData", "backups"), cleanupResult.FirstError); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// Verifies that a restore failure keeps the backups directory intact, so the user's pristine + /// originals are still recoverable by hand after a delete-all. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenRestoreFails_RetainsBackupsAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + var backupsPath = Path.Combine(_appDataDir, "UserData", "backups"); + + // The caller must be told, and told where: "all user data deleted successfully" is a lie + // while the user's pristine originals are still sitting in the backups folder. + Assert.False(deleteResult.Success); + Assert.Contains(backupsPath, deleteResult.FirstError); + + Assert.True(Directory.Exists(backupsPath)); + Assert.NotEmpty(Directory.GetFiles(backupsPath, "*", SearchOption.AllDirectories)); + + // The manifests and the index are the only map from a machine-named backup file back to the + // path it belongs at, so retaining the backups while deleting them would strand them. + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// A delete-all that retains backups keeps its tracking data, so retrying it once the restores + /// can succeed must still finish the job rather than leave the tracking directory behind forever. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_RetriedAfterRetention_ClearsEverythingAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Failed); + + var firstAttempt = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + Assert.False(firstAttempt.Success); + + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + + // Act + var retry = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(retry.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.Empty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData"), "*", SearchOption.AllDirectories)); + } + + /// + /// Deactivation puts the user's original back at its own path, which consumes the backup. Keeping + /// the backup file and its recorded path would make the following uninstall read that restored + /// original as a user modification, move the byte-identical file aside and restore a duplicate. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeactivateThenUninstall_DoesNotDuplicateTheRestoredOriginalAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath; + Assert.NotNull(backupPath); + + // Only the deployed CAS content matches the recorded hash; the user's own file does not. + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string path, string hash, CancellationToken _) => + File.Exists(path) && File.ReadAllText(path) == CasContent + ? FileHashVerification.Match + : FileHashVerification.Mismatch); + + // Act + var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync(TestProfileId, CancellationToken.None); + Assert.True(deactivateResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(backupPath)); + + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + } + + /// + /// The restore is what protects the user's data; deleting the consumed backup afterwards is + /// housekeeping. A delete that fails must not report the restore as failed, because the retry + /// would read the restored original as a modification and duplicate it. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenConsumedBackupCannotBeDeleted_StillReportsSuccessAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var backupPath = installResult.Data!.InstalledFiles[0].BackupPath!; + var backupDir = Path.GetDirectoryName(backupPath)!; + + // Deleting the backup has to fail while reading it still works: an open handle does that on + // Windows, and a directory the process may not write to does it everywhere else. + FileStream? openBackupHandle = null; + UnixFileMode? originalDirectoryMode = null; + string? probePath = null; + if (OperatingSystem.IsWindows()) + { + openBackupHandle = new FileStream(backupPath, System.IO.FileMode.Open, FileAccess.Read, FileShare.Read); + } + else + { + probePath = Path.Combine(backupDir, "delete-permission-probe"); + File.WriteAllText(probePath, string.Empty); + + originalDirectoryMode = File.GetUnixFileMode(backupDir); + File.SetUnixFileMode(backupDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + if (DeleteSucceeds(probePath)) + { + // The mode is advisory for this process: root, and anything else holding + // CAP_DAC_OVERRIDE, deletes regardless. There is no failing delete left to set up, + // so the scenario cannot be reached here rather than the product being wrong. + File.SetUnixFileMode(backupDir, originalDirectoryMode.Value); + return; + } + } + + try + { + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.True(uninstallResult.Success); + Assert.True(File.Exists(backupPath)); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + Assert.False(File.Exists(deployedPath + UserDataConstants.UserModifiedSuffix)); + } + finally + { + openBackupHandle?.Dispose(); + if (!OperatingSystem.IsWindows() && originalDirectoryMode.HasValue) + { + File.SetUnixFileMode(backupDir, originalDirectoryMode.Value); + } + + if (probePath is not null) + { + File.Delete(probePath); + } + } + } + + /// + /// A cancelled delete-all must abort before any tracking metadata is destroyed. Swallowing the + /// cancellation and carrying on wipes the manifests and the index while the backups they describe + /// are still on disk, leaving the user's originals unrecoverable by anything but hand. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenCancelledMidCleanup_KeepsTrackingMetadataAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + using var cts = new CancellationTokenSource(); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((_, _, token) => + { + cts.Cancel(); + token.ThrowIfCancellationRequested(); + return Task.FromResult(FileHashVerification.Match); + }); + + // Act + await Assert.ThrowsAnyAsync(() => _trackerService.DeleteAllUserDataAsync(cts.Token)); + + // Assert + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "backups"), "*", SearchOption.AllDirectories)); + } + + /// + /// Cancellation that lands on the manifest read itself must abort the delete-all too. Treating + /// the cancelled read as an unreadable manifest turns an abort into a retention decision and + /// carries on into the step that removes the tracking data. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenCancelledLoadingManifest_KeepsTrackingMetadataAsync() + { + // Arrange + const string secondHash = "hash-splash-safety-second"; + const string secondRelativePath = "GeneralsOnlineGameData/loading.bmp"; + File.WriteAllText(Path.Combine(_casDir, secondHash), CasContent); + + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + Assert.True((await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None)).Success); + + Assert.True((await _trackerService.InstallUserDataAsync( + TestManifestId + ".loading", + TestProfileId, + GameType.ZeroHour, + BuildFiles(secondRelativePath, secondHash), + TestVersion, + TestManifestName, + CancellationToken.None)).Success); + + // Cancel while the first installation is being cleaned up, so the cancellation is first + // observed by the read of the second installation's manifest. + using var cts = new CancellationTokenSource(); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + cts.Cancel(); + return FileHashVerification.Match; + }); + + // Act + await Assert.ThrowsAnyAsync(() => _trackerService.DeleteAllUserDataAsync(cts.Token)); + + // Assert + Assert.True(File.Exists(Path.Combine(_appDataDir, "UserData", "index.json"))); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(_appDataDir, "UserData", "manifests"), "*", SearchOption.AllDirectories)); + } + + /// + /// An index key whose manifest is already gone has nothing left to restore, so it must not put + /// delete-all into the retention path forever: "Delete All Application Data" would then never be + /// able to finish on an installation with one stale entry. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WithStaleIndexEntry_StillClearsEverythingAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "the-user-original-file"); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + var userDataPath = Path.Combine(_appDataDir, "UserData"); + foreach (var manifestFile in Directory.GetFiles(Path.Combine(userDataPath, "manifests"), "*", SearchOption.AllDirectories)) + { + File.Delete(manifestFile); + } + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(deleteResult.Success); + Assert.Empty(Directory.GetFiles(userDataPath, "*", SearchOption.AllDirectories)); + } + + /// + /// Verifies that a clean delete-all still restores the originals and clears the backups, so the + /// retention path does not become the permanent behaviour. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteAllUserDataAsync_WhenRestoresSucceed_ClearsBackupsAsync() + { + // Arrange + const string originalUserContent = "the-user-original-file"; + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, originalUserContent); + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + + // Act + var deleteResult = await _trackerService.DeleteAllUserDataAsync(CancellationToken.None); + + // Assert + Assert.True(deleteResult.Success); + Assert.Equal(originalUserContent, File.ReadAllText(deployedPath)); + + var backupsPath = Path.Combine(_appDataDir, "UserData", "backups"); + Assert.True(Directory.Exists(backupsPath)); + Assert.Empty(Directory.GetFiles(backupsPath, "*", SearchOption.AllDirectories)); + } + + [LibraryImport("kernel32.dll", EntryPoint = "CreateHardLinkW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CreateHardLinkWindows(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + + [LibraryImport("libc", EntryPoint = "link", SetLastError = true, StringMarshalling = StringMarshalling.Utf8)] + private static partial int LinkUnix(string existingPath, string newPath); + + private static List BuildFiles() => BuildFiles(TestRelativePath, TestHash); + + private static List BuildFiles(string relativePath, string hash) => + [ + new() + { + RelativePath = relativePath, + Hash = hash, + Size = CasContent.Length, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + ]; + + private static bool TryCreateHardLink(string existingPath, string linkPath) + { + try + { + if (File.Exists(linkPath)) + { + File.Delete(linkPath); + } + + return OperatingSystem.IsWindows() + ? CreateHardLinkWindows(linkPath, existingPath, IntPtr.Zero) + : LinkUnix(existingPath, linkPath) == 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or EntryPointNotFoundException or DllNotFoundException) + { + return false; + } + } + + /// + /// Reports whether a delete inside a directory whose mode was just tightened still goes through. + /// A process holding CAP_DAC_OVERRIDE - root in a dev container or a privileged CI image - is + /// not bound by the mode, so a test that assumed the delete would fail would instead report the + /// product as broken. + /// + /// The probe file the tightened directory is meant to protect. + /// true when the delete succeeded despite the directory mode. + private static bool DeleteSucceeds(string path) + { + try + { + File.Delete(path); + return !File.Exists(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs index 2babf7343..ae8e591cd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -83,6 +83,25 @@ public UserDataTrackerServiceTests() }) .ReturnsAsync(true); + // Default mock for CAS copying: user-writable destinations are always copied, never linked + _fileOperationsMock + .Setup(f => f.CopyFromCasAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((hash, targetPath, contentType, token) => + { + var dir = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText(targetPath, "cas-content-" + hash); + }) + .ReturnsAsync(true); + _fileOperationsMock .Setup(f => f.VerifyFileHashAsync( It.IsAny(), @@ -90,6 +109,13 @@ public UserDataTrackerServiceTests() It.IsAny())) .ReturnsAsync(true); + _fileOperationsMock + .Setup(f => f.CheckFileHashAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(FileHashVerification.Match); + _trackerService = new UserDataTrackerService( _configProviderMock.Object, _fileOperationsMock.Object, diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs index 8340cc899..ac9b2e0c4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs @@ -1,6 +1,7 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Results; using GenHub.Features.Workspace; using Microsoft.Extensions.Logging; @@ -49,6 +50,97 @@ public async Task CopyFileAsync_CreatesFileAsync() Assert.Equal("test content", await File.ReadAllTextAsync(dst)); } + /// + /// A copy that cannot even open its source must not have destroyed the file already sitting at + /// the destination: the destination is unlinked to break hard links, and doing that before the + /// source is known to be readable turns a failed copy into data loss. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_MissingSource_LeavesExistingDestinationIntactAsync() + { + var src = Path.Combine(_tempDir, "missing-source.txt"); + var dst = Path.Combine(_tempDir, "existing-destination.txt"); + + await File.WriteAllTextAsync(dst, "the file the user already had"); + + await Assert.ThrowsAsync(() => _service.CopyFileAsync(src, dst)); + + Assert.True(File.Exists(dst)); + Assert.Equal("the file the user already had", await File.ReadAllTextAsync(dst)); + } + + /// + /// Copying a file onto itself must leave it alone rather than unlinking it and then failing to + /// read the source it has just deleted. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_SameSourceAndDestination_LeavesFileIntactAsync() + { + var file = Path.Combine(_tempDir, "self.txt"); + await File.WriteAllTextAsync(file, "irreplaceable content"); + + await _service.CopyFileAsync(file, Path.Combine(_tempDir, ".", "self.txt")); + + Assert.True(File.Exists(file)); + Assert.Equal("irreplaceable content", await File.ReadAllTextAsync(file)); + } + + /// + /// A destination that is a leftover link to the source is exactly what callers copy to get rid + /// of: skipping the copy because the link resolves to the source leaves the workspace file + /// pointing at the shared CAS object, so later writes reach the object every profile shares. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_DestinationIsSymlinkToSource_ReplacesLinkWithIndependentCopyAsync() + { + var file = Path.Combine(_tempDir, "real.txt"); + var link = Path.Combine(_tempDir, "link.txt"); + await File.WriteAllTextAsync(file, "shared content"); + + if (!TryCreateSymbolicLink(link, file)) + { + return; + } + + await _service.CopyFileAsync(file, link); + + Assert.Null(File.ResolveLinkTarget(link, returnFinalTarget: true)); + Assert.Equal("shared content", await File.ReadAllTextAsync(link)); + + await File.WriteAllTextAsync(link, "workspace content"); + + Assert.Equal("shared content", await File.ReadAllTextAsync(file)); + Assert.Equal("workspace content", await File.ReadAllTextAsync(link)); + } + + /// + /// When the source is the link and the destination is the real file it points at, the + /// destination is already the independent copy the caller wants. Unlinking it would destroy the + /// only copy of the content, so the copy must be skipped. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CopyFileAsync_SourceIsSymlinkToDestination_LeavesFileIntactAsync() + { + var file = Path.Combine(_tempDir, "target.txt"); + var link = Path.Combine(_tempDir, "pointer.txt"); + await File.WriteAllTextAsync(file, "irreplaceable content"); + + if (!TryCreateSymbolicLink(link, file)) + { + return; + } + + await _service.CopyFileAsync(link, file); + + Assert.True(File.Exists(file)); + Assert.Null(File.ResolveLinkTarget(file, returnFinalTarget: true)); + Assert.Equal("irreplaceable content", await File.ReadAllTextAsync(file)); + } + /// /// Tests that CreateSymlinkAsync creates a symbolic link or falls back to copy on unsupported platforms. /// @@ -282,6 +374,22 @@ public async Task VerifyFileHashAsync_ReturnsFalse_WhenFileNotExistsAsync() Times.Never); } + /// + /// A file that is not there yields no hash at all, so it must be reported as a failed check + /// rather than as a confirmed difference that a destructive caller could act on. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckFileHashAsync_MissingFile_ReportsFailedAsync() + { + var missing = Path.Combine(_tempDir, "not-here.txt"); + + var result = await _service.CheckFileHashAsync(missing, "any-hash"); + + Assert.Equal(FileHashVerification.Failed, result); + Assert.False(await _service.VerifyFileHashAsync(missing, "any-hash")); + } + /// /// Tests that VerifyFileHashAsync handles exceptions gracefully. /// @@ -346,4 +454,24 @@ public void Dispose() { FileOperationsService.DeleteDirectoryIfExists(_tempDir); } + + /// + /// Creates a symbolic link, reporting failure rather than throwing when the platform withholds + /// the privilege it needs. + /// + /// The link to create. + /// The file the link points at. + /// True when the link was created. + private static bool TryCreateSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs index f503f2603..159954c17 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/TestFileOperationsService.cs @@ -80,6 +80,10 @@ public Task CreateSymlinkAsync(string linkPath, string targetPath, bool allowFal public Task VerifyFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) => _innerService.VerifyFileHashAsync(filePath, expectedHash, cancellationToken); + /// + public Task CheckFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) + => _innerService.CheckFileHashAsync(filePath, expectedHash, cancellationToken); + /// public Task ApplyPatchAsync(string targetPath, string patchPath, CancellationToken cancellationToken = default) => _innerService.ApplyPatchAsync(targetPath, patchPath, cancellationToken); diff --git a/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs b/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs index 6c2ba2531..92d155a20 100644 --- a/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs +++ b/GenHub/GenHub.Windows/Features/Workspace/WindowsFileOperationsService.cs @@ -33,6 +33,10 @@ public Task CreateSymlinkAsync(string linkPath, string targetPath, bool allowFal public Task VerifyFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) => baseService.VerifyFileHashAsync(filePath, expectedHash, cancellationToken); + /// + public Task CheckFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) + => baseService.CheckFileHashAsync(filePath, expectedHash, cancellationToken); + /// public Task DownloadFileAsync(Uri url, string destinationPath, IProgress? progress = null, CancellationToken cancellationToken = default) => baseService.DownloadFileAsync(url, destinationPath, progress, cancellationToken); diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 07322e27e..1e6fc31a1 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -68,6 +68,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable private readonly IGameInstallationService _installationService; private readonly IStorageLocationService _storageLocationService; private readonly IUserDataTracker _userDataTracker; + private readonly IDialogService _dialogService; private bool _isViewVisible; private bool _disposed; @@ -216,6 +217,7 @@ public partial class SettingsViewModel : ObservableObject, IDisposable /// Game installation service. /// Storage location service. /// User data tracker service. + /// Dialog service used to confirm destructive actions. /// GitHub token storage. public SettingsViewModel( IUserSettingsService userSettingsService, @@ -230,6 +232,7 @@ public SettingsViewModel( IGameInstallationService installationService, IStorageLocationService storageLocationService, IUserDataTracker userDataTracker, + IDialogService dialogService, IGitHubTokenStorage? gitHubTokenStorage = null) { _userSettingsService = userSettingsService ?? throw new ArgumentNullException(nameof(userSettingsService)); @@ -244,6 +247,7 @@ public SettingsViewModel( _installationService = installationService ?? throw new ArgumentNullException(nameof(installationService)); _storageLocationService = storageLocationService ?? throw new ArgumentNullException(nameof(storageLocationService)); _userDataTracker = userDataTracker ?? throw new ArgumentNullException(nameof(userDataTracker)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); _gitHubTokenStorage = gitHubTokenStorage; LoadSettings(); @@ -1056,22 +1060,54 @@ private void OpenUpdateWindow() [RelayCommand] private async Task DeleteAllData() { - _logger.LogWarning("Deleting ALL application data requested"); - - await DeleteProfiles(); - await DeleteWorkspaces(); - await DeleteManifests(); - await DeleteCasStorage(); - await DeleteUserData(); - - // Invalidate installation cache to force re-generation of manifests on next scan - _installationService.InvalidateCache(); - - await UpdateDangerZoneDataAsync(); - _notificationService.ShowSuccess( - "Data Deleted", - $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}", - 5000); + try + { + _logger.LogWarning("Deleting ALL application data requested"); + + var confirmed = await _dialogService.ShowConfirmationAsync( + AppConstants.DeleteAllDataConfirmationTitle, + AppConstants.DeleteAllDataConfirmationMessage, + confirmText: AppConstants.DeleteAllDataConfirmText); + + if (!confirmed) + { + _logger.LogInformation("Deleting ALL application data was cancelled at the confirmation prompt"); + return; + } + + await DeleteProfiles(); + await DeleteWorkspaces(); + await DeleteManifests(); + await DeleteCasStorage(); + var userDataDeleted = await DeleteUserDataInternalAsync(); + + // Invalidate installation cache to force re-generation of manifests on next scan + _installationService.InvalidateCache(); + + await UpdateDangerZoneDataAsync(); + + // A success toast on top of the partial-failure toast the user data deletion just raised + // would tell the user their data is gone while their originals are still on disk. + if (userDataDeleted) + { + _notificationService.ShowSuccess( + "Data Deleted", + $"Profiles, workspaces, manifests, and user data were deleted. {CasDefaults.GarbageCollectionDisabledMessage}", + 5000); + } + else + { + _notificationService.ShowWarning( + "Data Partially Deleted", + $"Profiles, workspaces, and manifests were deleted, but some user data was kept. {CasDefaults.GarbageCollectionDisabledMessage}", + 5000); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete all application data"); + _notificationService.ShowError("Deletion Failed", $"Failed to delete all application data: {ex.Message}", 5000); + } } [RelayCommand] @@ -1288,19 +1324,42 @@ private async Task CleanupOrphanedWorkspaceDirectoriesAsync() [RelayCommand] private async Task DeleteUserData() + { + await DeleteUserDataInternalAsync(); + } + + /// + /// Deletes the tracked user data and reports whether everything was actually removed, so a + /// caller that follows it with a summary message cannot contradict the partial-failure it raised. + /// + /// true when all tracked user data was deleted; otherwise, false. + private async Task DeleteUserDataInternalAsync() { try { _logger.LogWarning("Deleting all user data"); - await _userDataTracker.DeleteAllUserDataAsync(); - _notificationService.ShowSuccess("User Data Deleted", "All user data deleted successfully.", 3000); + var result = await _userDataTracker.DeleteAllUserDataAsync(); + if (result.Success) + { + _notificationService.ShowSuccess("User Data Deleted", "All user data deleted successfully.", 3000); + } + else + { + _logger.LogWarning("User data deletion kept some data: {Error}", result.FirstError); + _notificationService.ShowError( + "User Data Partially Deleted", + result.FirstError ?? "Some tracked user data could not be deleted.", + 5000); + } await UpdateDangerZoneDataAsync(); + return result.Success; } catch (Exception ex) { _logger.LogError(ex, "Failed to delete user data"); _notificationService.ShowError("Deletion Failed", $"Failed to delete user data: {ex.Message}", 5000); + return false; } } diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs index 9deb34afd..365c98a1f 100644 --- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs @@ -77,8 +77,18 @@ public async Task> PrepareProfileUserDataAsync( "[ProfileContentLinker] User data verification failed for {ManifestId}, reinstalling", manifest.Id.Value); - // Reinstall - await userDataTracker.UninstallUserDataAsync(manifest.Id.Value, profileId, cancellationToken); + // Reinstall, but never on top of an uninstall that could not put the user's + // originals back: redeploying would bury the unfinished restore. + var uninstallResult = await userDataTracker.UninstallUserDataAsync(manifest.Id.Value, profileId, cancellationToken); + if (!uninstallResult.Success) + { + logger.LogError( + "[ProfileContentLinker] Cannot reinstall {ManifestId}: the previous installation could not be fully removed: {Error}", + manifest.Id.Value, + uninstallResult.FirstError); + return OperationResult.CreateFailure(uninstallResult.Errors); + } + await InstallManifestUserDataAsync(manifest, profileId, targetGame, cancellationToken); } else if (!existingResult.Data.IsActive) @@ -256,10 +266,19 @@ public async Task> UpdateProfileUserDataAsync( // Find manifests to remove (in current but not in new) var toRemove = currentManifestIds.Except(newManifestIds).ToList(); + var uninstallErrors = new List(); foreach (var manifestId in toRemove) { logger.LogInformation("[ProfileContentLinker] Removing deselected content: {ManifestId}", manifestId); - await userDataTracker.UninstallUserDataAsync(manifestId, profileId, cancellationToken); + var uninstallResult = await userDataTracker.UninstallUserDataAsync(manifestId, profileId, cancellationToken); + if (!uninstallResult.Success) + { + logger.LogError( + "[ProfileContentLinker] Failed to remove deselected content {ManifestId}: {Error}", + manifestId, + uninstallResult.FirstError); + uninstallErrors.AddRange(uninstallResult.Errors); + } } // Find manifests to add (in new but not in current) @@ -292,7 +311,9 @@ public async Task> UpdateProfileUserDataAsync( toRemove.Count, toAdd.Count); - return OperationResult.CreateSuccess(true); + return uninstallErrors.Count > 0 + ? OperationResult.CreateFailure(uninstallErrors) + : OperationResult.CreateSuccess(true); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs index edca4b3c0..e87f5354a 100644 --- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Extensions.Enums; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameSettings; using GenHub.Core.Interfaces.UserData; @@ -22,7 +23,8 @@ namespace GenHub.Features.UserData.Services; /// /// Service for tracking and managing user data files (maps, replays, etc.) /// that are installed to the user's Documents folder. -/// Uses hard links to CAS content when possible for efficient disk usage. +/// Content bound for a user-writable destination is always copied out of CAS so that later writes +/// by the game or by GenHub cannot reach the canonical CAS object. /// public class UserDataTrackerService( IConfigurationProviderService configProvider, @@ -122,8 +124,13 @@ public async Task> InstallUserDataAsync( var installResult = await InstallSingleUserDataFileAsync(file, targetPath, targetGame, userDataManifest.InstallationKey, priorEntry, cancellationToken); if (!installResult.Success || installResult.Data == null) { - await CleanupInstalledFilesAsync(userDataManifest, CancellationToken.None); - return OperationResult.CreateFailure(installResult.FirstError ?? $"Failed to install '{targetPath}'."); + var error = installResult.FirstError ?? $"Failed to install '{targetPath}'."; + if (!await CleanupFailedInstallAsync(userDataManifest, manifestId)) + { + error += $" Some of your original files could not be put back and were kept at '{_backupsPath}'."; + } + + return OperationResult.CreateFailure(error); } var entry = installResult.Data; @@ -143,13 +150,13 @@ public async Task> InstallUserDataAsync( } catch (OperationCanceledException) { - await CleanupInstalledFilesAsync(userDataManifest, CancellationToken.None); + _ = await CleanupFailedInstallAsync(userDataManifest, manifestId); throw; } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to persist manifest or update index for {ManifestId}; cleaning up installed files", manifestId); - await CleanupInstalledFilesAsync(userDataManifest, CancellationToken.None); + _ = await CleanupFailedInstallAsync(userDataManifest, manifestId); throw; } @@ -196,7 +203,17 @@ public async Task> UninstallUserDataAsync( var manifest = manifestResult.Data; - await CleanupInstalledFilesAsync(manifest, cancellationToken); + // Keep the manifest and index entry when a pristine original could not be put back: they + // are the only record of which backup belongs to which path, so discarding them would + // strand the user's originals under machine-generated names with nothing referencing them. + if (!await CleanupInstalledFilesAsync(manifest, cancellationToken)) + { + logger.LogError( + "[UserData] Uninstall of {ManifestId} left one or more pristine backups unrestored; keeping its tracking data so the originals stay recoverable", + manifestId); + return OperationResult.CreateFailure( + $"Uninstalled files for '{manifestId}' but could not restore every original. Your originals are still under '{_backupsPath}' and GenHub kept tracking them so the uninstall can be retried."); + } // Remove the manifest file await DeleteUserDataManifestAsync(manifestId, profileId, cancellationToken); @@ -352,8 +369,9 @@ public async Task> DeactivateProfileUserDataAsync( Directory.CreateDirectory(targetDir); } - File.Copy(file.BackupPath, file.AbsolutePath, overwrite: true); - logger.LogInformation("[UserData] Restored backup during deactivation: {Backup} -> {Path}", file.BackupPath, file.AbsolutePath); + var restoredFrom = file.BackupPath; + RestoreAndConsumeBackup(file, logger); + logger.LogInformation("[UserData] Restored backup during deactivation: {Backup} -> {Path}", restoredFrom, file.AbsolutePath); } catch (OperationCanceledException) { @@ -459,6 +477,10 @@ public async Task>> GetGameUserD return OperationResult>.CreateSuccess(manifests); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to get game user data for {Game}", targetGame); @@ -478,6 +500,11 @@ public async Task>> GetGameUserD var manifest = await LoadUserDataManifestByKeyAsync(key, cancellationToken); return OperationResult.CreateSuccess(manifest); } + catch (OperationCanceledException) + { + // A cancelled read must not reach the uninstall path as "no manifest, nothing to do". + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to get user data manifest {ManifestId}/{ProfileId}", manifestId, profileId); @@ -570,14 +597,35 @@ public async Task> CleanupProfileAsync( return OperationResult.CreateSuccess(true); } + var uninstallErrors = new List(); foreach (var manifest in manifestsResult.Data) { - await UninstallUserDataAsync(manifest.ManifestId, profileId, cancellationToken); + var uninstallResult = await UninstallUserDataAsync(manifest.ManifestId, profileId, cancellationToken); + if (!uninstallResult.Success) + { + uninstallErrors.AddRange(uninstallResult.Errors); + } + } + + // A discarded uninstall failure is a silent data-safety failure: the user's pristine + // originals are still under the backups tree and nothing above would ever say so. + if (uninstallErrors.Count > 0) + { + logger.LogError( + "[UserData] Cleanup of profile {ProfileId} left {Count} uninstall(s) unfinished; their originals are still tracked under {BackupsPath}", + profileId, + uninstallErrors.Count, + _backupsPath); + return OperationResult.CreateFailure(uninstallErrors); } logger.LogInformation("[UserData] Cleaned up {Count} manifests for profile {ProfileId}", manifestsResult.Data.Count, profileId); return OperationResult.CreateSuccess(true); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to cleanup profile {ProfileId}", profileId); @@ -627,6 +675,7 @@ public async Task> DeleteAllUserDataAsync(CancellationToke var index = await LoadIndexUnlockedAsync(cancellationToken); // Uninstall all installations (this handles backup restoration and file deletion) + var allBackupsRestored = true; foreach (var profileId in index.ProfileInstallations.Keys.ToList()) { // Get keys for this profile @@ -637,26 +686,48 @@ public async Task> DeleteAllUserDataAsync(CancellationToke try { // We are already holding the lock, so we can't call UninstallUserDataAsync which tries to acquire it. - // Instead, we directly clean up the files. We don't need to update the index or delete the manifest file - // because we are about to delete the entire UserData directory. + // Instead, we directly clean up the files. var manifest = await LoadUserDataManifestByKeyAsync(key, cancellationToken); - if (manifest != null) + if (manifest == null) { - await CleanupInstalledFilesAsync(manifest, cancellationToken); + // A key whose manifest file is simply gone is a stale index entry + // with nothing left to restore, and must not block the cleanup + // forever. Only a manifest that exists but cannot be read leaves + // backups we can no longer put back. + if (File.Exists(GetManifestFilePath(key))) + { + logger.LogError("[UserData] Manifest for installation key {Key} could not be read; its backups cannot be restored", key); + allBackupsRestored = false; + } + else + { + logger.LogWarning("[UserData] Index entry {Key} has no manifest; nothing to restore for it", key); + } + + continue; + } + + if (!await CleanupInstalledFilesAsync(manifest, cancellationToken)) + { + allBackupsRestored = false; } } + catch (OperationCanceledException) + { + // Abort before step 3 removes the manifests and the index: those are + // the only map from a backup file back to the path it belongs at. + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to cleanup user data for installation key {Key}", key); + allBackupsRestored = false; } } } } - // 2. Clear the in-memory index - _cachedIndex = new UserDataIndex(); - - // 3. Nuke the directories to be sure + // 2. Nuke the directories to be sure if (Directory.Exists(_userDataTrackingPath)) { // Sanity check: ensure we're not deleting a system root or unrelated directory @@ -666,13 +737,32 @@ public async Task> DeleteAllUserDataAsync(CancellationToke return OperationResult.CreateFailure("UserData tracking path does not appear to be application-specific"); } - logger.LogInformation("[UserData] Deleting UserData directory: {Path}", _userDataTrackingPath); - Directory.Delete(_userDataTrackingPath, true); + if (allBackupsRestored) + { + logger.LogInformation("[UserData] Deleting UserData directory: {Path}", _userDataTrackingPath); + Directory.Delete(_userDataTrackingPath, true); + _cachedIndex = new UserDataIndex(); + } + else + { + // Keep the manifests and the index alongside the retained backups: they are + // the only map from a machine-named backup file back to the path it belongs + // at, and a later delete-all clears whatever is left once the restores work. + logger.LogWarning( + "[UserData] One or more pristine game data backups could not be restored, so they were NOT deleted. Your originals remain at {BackupsPath} and GenHub kept tracking them; retry the deletion or restore them by hand.", + _backupsPath); + } } - // 4. Re-create empty directories + // 3. Re-create empty directories EnsureDirectoriesExist(); + if (!allBackupsRestored) + { + return OperationResult.CreateFailure( + $"Removed what could be removed, but one or more pristine game data backups could not be restored. Your originals were kept at '{_backupsPath}' along with the tracking data that records where each one belongs, so the deletion can be retried."); + } + return OperationResult.CreateSuccess(true); } finally @@ -680,6 +770,10 @@ public async Task> DeleteAllUserDataAsync(CancellationToke IndexLock.Release(); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "[UserData] Failed to delete all user data"); @@ -730,19 +824,95 @@ private static string StripLeadingDirectory(string path, string directoryName) return path; } - private static void RestoreBackupQuietly(string? backupPath, string targetPath, bool wasOverwritten, ILogger? logger = null) + /// + /// Moves a deployed file that no longer matches its recorded hash to a clearly named sibling so + /// the user's edit is never discarded when the pristine backup is restored over the original path. + /// + /// The deployed file to move aside. + /// The path the modified file was moved to. + private static string MoveModifiedFileAside(string filePath) + { + var preservedPath = filePath + UserDataConstants.UserModifiedSuffix; + var attempt = 1; + while (File.Exists(preservedPath) || Directory.Exists(preservedPath)) + { + preservedPath = $"{filePath}{UserDataConstants.UserModifiedSuffix}.{attempt}"; + attempt++; + } + + File.Move(filePath, preservedPath); + return preservedPath; + } + + /// + /// Copies a backup back over a deployed path, unlinking the destination first. An older install + /// may have left a hard link to a CAS object there, and copying onto it in place would write the + /// backup's content into the canonical object rather than replacing the deployed file. + /// + /// The backup to restore from. + /// The path to restore to. + private static void RestoreBackupCopy(string backupPath, string targetPath) + { + FileOperationsService.DeleteFileIfExists(targetPath); + File.Copy(backupPath, targetPath, overwrite: true); + } + + /// + /// Restores a backup over the deployed path and consumes it. The protected content is back where + /// it belongs, so leaving the backup file and its recorded path behind would make the next + /// uninstall read the restored original as a user modification, move it aside and put an + /// identical duplicate in its place. + /// + /// The entry whose backup should be restored and then cleared. + /// The logger used to record a backup file that could not be deleted. + private static void RestoreAndConsumeBackup(UserDataFileEntry file, ILogger logger) + { + var backupPath = file.BackupPath!; + RestoreBackupCopy(backupPath, file.AbsolutePath); + file.BackupPath = null; + file.WasOverwritten = false; + + DeleteConsumedBackup(backupPath, logger); + } + + /// + /// Deletes a backup whose content has already been put back at the path it belongs to. The + /// restore is what protects the user's data, so a delete that fails - an antivirus scanner or an + /// indexer holding the file open for a moment - must not turn the restore into a failure: the + /// retry would read the restored original as a modification and duplicate it. + /// + /// The backup file to remove. + /// The logger used to record a backup file that could not be deleted. + private static void DeleteConsumedBackup(string backupPath, ILogger logger) + { + try + { + File.Delete(backupPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + ex, + "[UserData] Restored backup {BackupPath} but could not delete it; it is now a stray copy and can be removed by hand", + backupPath); + } + } + + private static void RestoreBackupQuietly(string? backupPath, string targetPath, bool wasOverwritten, ILogger logger) { if (wasOverwritten && !string.IsNullOrEmpty(backupPath) && File.Exists(backupPath)) { try { - File.Copy(backupPath, targetPath, overwrite: true); - File.Delete(backupPath); + RestoreBackupCopy(backupPath, targetPath); } catch (Exception ex) { - logger?.LogWarning(ex, "[UserData] Failed to restore safety backup from {BackupPath} to {TargetPath}", backupPath, targetPath); + logger.LogWarning(ex, "[UserData] Failed to restore safety backup from {BackupPath} to {TargetPath}", backupPath, targetPath); + return; } + + DeleteConsumedBackup(backupPath, logger); } } @@ -935,24 +1105,16 @@ private async Task> ActivateSingleFileAsync( var fileMaterialized = false; try { - var linkResult = await fileOperations.LinkFromCasAsync( + var (materialized, isHardLink) = await MaterializeFromCasAsync( file.CasHash, file.AbsolutePath, - useHardLink: true, - contentType: null, - cancellationToken: cancellationToken); + file.InstallTarget, + cancellationToken); - if (linkResult) + fileMaterialized = materialized; + if (materialized) { - fileMaterialized = true; - } - else - { - var copyResult = await fileOperations.CopyFromCasAsync(file.CasHash, file.AbsolutePath, contentType: null, cancellationToken: cancellationToken); - if (copyResult) - { - fileMaterialized = true; - } + file.IsHardLink = isHardLink; } } catch (OperationCanceledException) @@ -1038,7 +1200,7 @@ private async Task> InstallSingleUserDataFile return OperationResult.CreateFailure($"File '{file.RelativePath}' has no hash. Installation aborted."); } - var (materialized, isHardLink) = await MaterializeFileFromCasAsync(file.Hash, targetPath, backupPath, wasOverwritten, cancellationToken); + var (materialized, isHardLink) = await MaterializeFileFromCasAsync(file.Hash, targetPath, file.InstallTarget, backupPath, wasOverwritten, cancellationToken); if (!materialized) { logger.LogError("[UserData] Failed to install file {Path}; aborting installation", targetPath); @@ -1064,31 +1226,14 @@ private async Task> InstallSingleUserDataFile private async Task<(bool Materialized, bool IsHardLink)> MaterializeFileFromCasAsync( string hash, string targetPath, + ContentInstallTarget installTarget, string? backupPath, bool wasOverwritten, CancellationToken cancellationToken) { try { - var linkResult = await fileOperations.LinkFromCasAsync( - hash, - targetPath, - useHardLink: true, - contentType: null, - cancellationToken: cancellationToken); - - if (linkResult) - { - logger.LogDebug("[UserData] Created hard link for {Path}", targetPath); - return (true, true); - } - - var copyResult = await fileOperations.CopyFromCasAsync(hash, targetPath, contentType: null, cancellationToken: cancellationToken); - if (copyResult) - { - logger.LogDebug("[UserData] Copied file for {Path} (hard link failed)", targetPath); - return (true, false); - } + return await MaterializeFromCasAsync(hash, targetPath, installTarget, cancellationToken); } catch (OperationCanceledException) { @@ -1103,6 +1248,58 @@ private async Task> InstallSingleUserDataFile return (false, false); } + /// + /// Materializes CAS content at the destination. User-writable destinations always receive an + /// independent copy: a hard link would share the underlying storage with the CAS object, so any + /// in-place write by the game or by GenHub would rewrite the canonical object and break the + /// hash-to-content invariant for every profile referencing it. + /// + /// The CAS hash of the content to materialize. + /// The destination file path. + /// The install target the destination was resolved from. + /// A cancellation token. + /// Whether the file was materialized and whether it is a hard link. + private async Task<(bool Materialized, bool IsHardLink)> MaterializeFromCasAsync( + string hash, + string targetPath, + ContentInstallTarget installTarget, + CancellationToken cancellationToken) + { + if (installTarget.IsUserWritableTarget()) + { + var userCopyResult = await fileOperations.CopyFromCasAsync(hash, targetPath, contentType: null, cancellationToken: cancellationToken); + if (userCopyResult) + { + logger.LogDebug("[UserData] Copied file for {Path} (user-writable destination)", targetPath); + return (true, false); + } + + return (false, false); + } + + var linkResult = await fileOperations.LinkFromCasAsync( + hash, + targetPath, + useHardLink: true, + contentType: null, + cancellationToken: cancellationToken); + + if (linkResult) + { + logger.LogDebug("[UserData] Created hard link for {Path}", targetPath); + return (true, true); + } + + var copyResult = await fileOperations.CopyFromCasAsync(hash, targetPath, contentType: null, cancellationToken: cancellationToken); + if (copyResult) + { + logger.LogDebug("[UserData] Copied file for {Path} (hard link failed)", targetPath); + return (true, false); + } + + return (false, false); + } + private void RollbackActivatedFiles(IReadOnlyList filesActivated, string userDataBasePath) { foreach (var file in filesActivated) @@ -1117,7 +1314,7 @@ private void RollbackActivatedFiles(IReadOnlyList filesActiva Directory.CreateDirectory(targetDir); } - File.Copy(file.BackupPath, file.AbsolutePath, overwrite: true); + RestoreAndConsumeBackup(file, logger); } else { @@ -1143,34 +1340,88 @@ private void RollbackActivatedFiles(IReadOnlyList filesActiva private string GetUserDataBasePath(GameType gameType) => pathProvider.GetOptionsDirectory(gameType); - private async Task CleanupInstalledFilesAsync(UserDataManifest manifest, CancellationToken cancellationToken) + /// + /// Rolls a failed installation back. The manifest has not been persisted at this point, so a + /// backup that cannot be put back is referenced by nothing at all; say so loudly rather than + /// leaving the user to identify a machine-named file in the backups tree. + /// + /// The partially installed manifest to roll back. + /// The manifest identifier, for logging. + /// true when every backup was restored; otherwise, false. + private async Task CleanupFailedInstallAsync(UserDataManifest manifest, string manifestId) + { + if (await CleanupInstalledFilesAsync(manifest, CancellationToken.None)) + { + return true; + } + + logger.LogError( + "[UserData] Rolling back the failed install of {ManifestId} left one or more originals unrestored; they are kept at {BackupsPath} but no manifest records where they belong", + manifestId, + _backupsPath); + return false; + } + + /// + /// Removes the deployed files for a manifest and restores the pristine originals GenHub backed up. + /// A deployed file confirmed to differ from its recorded hash is moved aside instead of being + /// discarded, so the user's edit survives and the backup can still be restored over the original + /// path. A file whose hash could not be computed is left alone: an unreadable or briefly locked + /// file is not evidence that the user changed it. + /// + /// The manifest whose installed files should be removed. + /// A cancellation token. + /// true when every backup for the manifest was restored; otherwise, false. + private async Task CleanupInstalledFilesAsync(UserDataManifest manifest, CancellationToken cancellationToken) { var userDataBasePath = GetUserDataBasePath(manifest.TargetGame); + var allBackupsRestored = true; + foreach (var file in manifest.InstalledFiles) { cancellationToken.ThrowIfCancellationRequested(); + var hasBackup = !string.IsNullOrEmpty(file.BackupPath) && File.Exists(file.BackupPath); + var backupRestored = false; + try { + var restoreNeeded = hasBackup; + if (File.Exists(file.AbsolutePath)) { - // Verify we should delete this file (hash matches) - if (await fileOperations.VerifyFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken)) - { - File.Delete(file.AbsolutePath); - logger.LogDebug("[UserData] Deleted file: {Path}", file.AbsolutePath); - - // Clean up empty directories up to the base user data path - CleanupEmptyDirectories(Path.GetDirectoryName(file.AbsolutePath), userDataBasePath); - } - else + switch (await fileOperations.CheckFileHashAsync(file.AbsolutePath, file.SourceHash, cancellationToken)) { - logger.LogWarning("[UserData] File hash mismatch, user may have modified: {Path}", file.AbsolutePath); + case FileHashVerification.Match: + File.Delete(file.AbsolutePath); + logger.LogDebug("[UserData] Deleted file: {Path}", file.AbsolutePath); + + CleanupEmptyDirectories(Path.GetDirectoryName(file.AbsolutePath), userDataBasePath); + break; + + case FileHashVerification.Mismatch when hasBackup: + var preservedPath = MoveModifiedFileAside(file.AbsolutePath); + logger.LogWarning( + "[UserData] File hash mismatch for {Path}; your modified copy was preserved at {PreservedPath} so the original could be restored", + file.AbsolutePath, + preservedPath); + break; + + case FileHashVerification.Mismatch: + restoreNeeded = false; + logger.LogWarning("[UserData] File hash mismatch and no backup to restore, leaving in place: {Path}", file.AbsolutePath); + break; + + default: + restoreNeeded = false; + logger.LogWarning( + "[UserData] Could not verify {Path} against its recorded hash, so it is left untouched along with any backup; the deployed file may still be pristine", + file.AbsolutePath); + break; } } - // Restore backup if exists and target file was removed or absent - if (!File.Exists(file.AbsolutePath) && !string.IsNullOrEmpty(file.BackupPath) && File.Exists(file.BackupPath)) + if (restoreNeeded) { var targetDir = Path.GetDirectoryName(file.AbsolutePath); if (!string.IsNullOrEmpty(targetDir)) @@ -1178,15 +1429,36 @@ private async Task CleanupInstalledFilesAsync(UserDataManifest manifest, Cancell Directory.CreateDirectory(targetDir); } - File.Move(file.BackupPath, file.AbsolutePath, overwrite: true); + // Delete-then-copy rather than File.Move: backups live under the application data + // tree while the deployed path is under Documents, which is routinely redirected + // to another drive or to OneDrive, and File.Move cannot cross a volume boundary. + RestoreBackupCopy(file.BackupPath!, file.AbsolutePath); + backupRestored = true; logger.LogInformation("[UserData] Restored backup: {Backup} -> {Path}", file.BackupPath, file.AbsolutePath); + + DeleteConsumedBackup(file.BackupPath!, logger); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogWarning(ex, "[UserData] Failed to uninstall file: {Path}", file.AbsolutePath); } + + if (hasBackup && !backupRestored) + { + allBackupsRestored = false; + logger.LogWarning( + "[UserData] Backup for {Path} was not restored; the recorded backup is {BackupPath}", + file.AbsolutePath, + file.BackupPath); + } } + + return allBackupsRestored; } private void EnsureDirectoriesExist() @@ -1293,8 +1565,10 @@ private async Task DeleteUserDataManifestAsync(string manifestId, string profile var json = await File.ReadAllTextAsync(filePath, cancellationToken); return JsonSerializer.Deserialize(json); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { + // Cancellation must escape: a caller that reads a null manifest as "unreadable, its + // backups can no longer be put back" would turn an abort into a retention decision. logger.LogWarning(ex, "[UserData] Failed to load manifest from {Path}", filePath); return null; } diff --git a/GenHub/GenHub/Features/Workspace/FileOperationsService.cs b/GenHub/GenHub/Features/Workspace/FileOperationsService.cs index cdb2d8275..fd1c5d744 100644 --- a/GenHub/GenHub/Features/Workspace/FileOperationsService.cs +++ b/GenHub/GenHub/Features/Workspace/FileOperationsService.cs @@ -152,24 +152,20 @@ public async Task CopyFileAsync( const int MaxRetries = 3; const int InitialDelayMs = 50; + if (WouldCopyOntoItself(sourcePath, destinationPath)) + { + logger.LogDebug("Skipped copy because source and destination are the same file: {Source}", sourcePath); + return; + } + for (int attempt = 0; attempt <= MaxRetries; attempt++) { try { EnsureDirectoryExists(destinationPath); - // If destination exists and is a symlink/reparse point, delete it first - // This prevents issues when switching from Symlink strategy to FullCopy strategy - if (File.Exists(destinationPath)) - { - var destInfo = new FileInfo(destinationPath); - if (destInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - logger.LogDebug("Removing existing symlink at {Destination} before copying", destinationPath); - destInfo.Delete(); - } - } - + // Open the source before touching the destination: a missing or unreadable source + // must fail without having destroyed a valid file already sitting at the destination. await using var source = new FileStream( sourcePath, FileMode.Open, @@ -177,6 +173,15 @@ public async Task CopyFileAsync( FileShare.Read, BufferSize, useAsync: true); + + // Always unlink an existing destination rather than truncating it. A symlink left by + // the Symlink strategy, or a hard link to a CAS object, would otherwise receive the + // write through to its target instead of yielding an independent copy here. + if (DeleteFileIfExists(destinationPath)) + { + logger.LogDebug("Removed existing destination at {Destination} before copying", destinationPath); + } + await using var destination = new FileStream( destinationPath, FileMode.Create, @@ -419,18 +424,38 @@ public async Task VerifyFileHashAsync( string filePath, string expectedHash, CancellationToken cancellationToken = default) + { + try + { + return await CheckFileHashAsync(filePath, expectedHash, cancellationToken) == FileHashVerification.Match; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to verify hash for {File}", filePath); + return false; + } + } + + /// + public async Task CheckFileHashAsync( + string filePath, + string expectedHash, + CancellationToken cancellationToken = default) { try { if (!File.Exists(filePath)) { - return false; + // No hash was computed, so nothing is known about the content that used to be here. + // Reporting a mismatch would invite a caller to act on a change it never observed. + logger.LogDebug("Hash verification for {File}: file does not exist", filePath); + return FileHashVerification.Failed; } var actualHash = await downloadService.ComputeFileHashAsync( filePath, cancellationToken); - var result = string.Equals( + var matches = string.Equals( actualHash, expectedHash, StringComparison.OrdinalIgnoreCase); @@ -438,13 +463,17 @@ public async Task VerifyFileHashAsync( logger.LogDebug( "Hash verification for {File}: {Result}", filePath, - result); - return result; + matches); + return matches ? FileHashVerification.Match : FileHashVerification.Mismatch; + } + catch (OperationCanceledException) + { + throw; } catch (Exception ex) { - logger.LogError(ex, "Failed to verify hash for {File}", filePath); - return false; + logger.LogError(ex, "Failed to compute hash for {File}", filePath); + return FileHashVerification.Failed; } } @@ -658,6 +687,97 @@ public async Task LinkFromCasAsync( } } + /// + /// Determines whether a copy would do nothing but unlink the file it is reading, in which case + /// there is nothing to copy and the file must be left alone. + /// + /// A destination that is itself a symbolic link never qualifies, even when it resolves to the + /// source. Callers copy precisely to replace such a link with an independent file, and unlinking + /// a link leaves the file it points at untouched. Only a destination that is a real file naming + /// the same file as the source qualifies - the identical path, or the file a source link points + /// at - because unlinking that would destroy the only copy. + /// + /// + /// Hard links and Windows 8.3 short names have no resolvable target and are not detected here; + /// opening the source before unlinking the destination is what makes those cases fail safely + /// rather than destructively. + /// + /// + /// The file being read. + /// The path being written. + /// True when the copy must be skipped. + private static bool WouldCopyOntoItself(string sourcePath, string destinationPath) + { + var source = TryGetFullPath(sourcePath); + var destination = TryGetFullPath(destinationPath); + + if (source is null || destination is null) + { + return false; + } + + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (string.Equals(source, destination, comparison)) + { + return true; + } + + if (TryResolveLinkTarget(destination) is not null) + { + return false; + } + + return string.Equals(TryResolveLinkTarget(source) ?? source, destination, comparison); + } + + /// + /// Normalizes a path without consulting the file system. + /// + /// The path to normalize. + /// The normalized path, or null when the path cannot be normalized. + private static string? TryGetFullPath(string path) + { + try + { + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + catch (ArgumentException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + catch (PathTooLongException) + { + return null; + } + } + + /// + /// Follows a symbolic link or junction to its final target. + /// + /// The normalized path to inspect. + /// The final target, or null when the path is not a link or cannot be read. + private static string? TryResolveLinkTarget(string fullPath) + { + try + { + var target = File.ResolveLinkTarget(fullPath, returnFinalTarget: true); + return target is null ? null : Path.TrimEndingDirectorySeparator(target.FullName); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + /// /// Determines if an IOException is due to file locking. /// diff --git a/GenHub/GenHub/Features/Workspace/UnixFileOperationsService.cs b/GenHub/GenHub/Features/Workspace/UnixFileOperationsService.cs index 7a096d8ec..d5ccb3f36 100644 --- a/GenHub/GenHub/Features/Workspace/UnixFileOperationsService.cs +++ b/GenHub/GenHub/Features/Workspace/UnixFileOperationsService.cs @@ -46,6 +46,10 @@ public Task CreateSymlinkAsync(string linkPath, string targetPath, bool allowFal public Task VerifyFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) => baseService.VerifyFileHashAsync(filePath, expectedHash, cancellationToken); + /// + public Task CheckFileHashAsync(string filePath, string expectedHash, CancellationToken cancellationToken = default) + => baseService.CheckFileHashAsync(filePath, expectedHash, cancellationToken); + /// public Task DownloadFileAsync(Uri url, string destinationPath, IProgress? progress = null, CancellationToken cancellationToken = default) => baseService.DownloadFileAsync(url, destinationPath, progress, cancellationToken); diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs index 22ac42494..f7ee6ace5 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/SharedViewModelModule.cs @@ -57,6 +57,7 @@ public static IServiceCollection AddSharedViewModelModule(this IServiceCollectio sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetService())); services.AddSingleton(); diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 628285b5c..a1d9ba2ce 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -61,6 +61,9 @@ Application-wide constants for GenHub. | `DefaultTheme` | `Theme.Dark` | Default UI theme | | `DefaultThemeName` | `"Dark"` | Default theme name as string | | `TokenFileName` | `".ghtoken"` | Default GitHub token file name | +| `DeleteAllDataConfirmationTitle` | `"Delete All Application Data"` | Title of the confirmation prompt shown before all application data is deleted | +| `DeleteAllDataConfirmationMessage` | string | Body of that prompt, warning that the deletion is irreversible and that pristine game data backups are discarded | +| `DeleteAllDataConfirmText` | `"Delete Everything"` | Confirm button text for the delete-all-application-data prompt | --- @@ -1358,6 +1361,7 @@ Constants for content pipeline component identifiers used in dependency injectio - **StorageConstants**: Storage and CAS operation constants - **TimeIntervals**: Time spans and intervals - **UiConstants**: User interface sizing and behavior +- **UserDataConstants**: Tracked user data installation constants - **ValidationLimits**: Input validation boundaries ### Best Practices @@ -1563,6 +1567,17 @@ Constants specifically for the Map Manager feature. --- +## UserDataConstants Class + +Constants for tracked user data installations — content GenHub deploys into the user's game data +folder under `Documents`. + +| Constant | Value | Description | +| -------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `UserModifiedSuffix` | `".user-modified"` | Suffix appended to a deployed file that no longer matches its recorded hash when it is moved aside so the pristine backup can be restored over it | + +--- + ## Related Documentation - [Manifest ID System](manifest-id-system.md) From 38f655e8d6c9ab8fb2e84d334da44e88e9209654 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:17:22 -0400 Subject: [PATCH 09/20] fix(config): preserve profiles, settings and workspace metadata across the v0.0.3 upgrade (#384) * fix(config): preserve profiles, settings and workspace metadata across the v0.0.3 upgrade * refactor(config): narrow legacy migration catches to file and path failures * refactor(core): add a shared path comparison helper and name the v0.0.3 layout constants * fix(config): migrate legacy data into the root the app reads from and probe the v0.0.3 Content layout * fix(settings): stop a failed initialization from saving defaults over the real settings file * refactor(userdata): reference constants for the tracked user data sub-paths * docs(workspace): correct the provenance of numeric workspace strategy values * fix(config): resolve the default content directories from the data root the app reads from * fix(settings): block saving when an existing settings file could not be read * fix(config): publish the legacy migration flag so the lock-free fast path is safe on weak memory models * fix(settings): refuse saves that would overwrite a settings file the session never read * feat(settings): surface a failed settings save instead of only logging it --- .../GenHub.Core/Constants/DirectoryNames.cs | 34 ++ GenHub/GenHub.Core/Constants/FileTypes.cs | 16 + GenHub/GenHub.Core/Helpers/PathHelper.cs | 23 + .../Interfaces/Common/IAppConfiguration.cs | 4 + .../Models/Enums/WorkspaceStrategy.cs | 31 +- .../JsonWorkspaceStrategyConverter.cs | 6 +- .../ConfigurationProviderServiceTests.cs | 419 +++++++++++++++++- .../Common/Services/LegacyRootUpgradeTests.cs | 402 +++++++++++++++++ .../Services/UserSettingsServiceTests.cs | 183 +++++++- .../ApplicationDataPathConventionTests.cs | 1 + .../Models/GameProfileDeserializationTests.cs | 56 ++- .../WorkspaceMetadataDeserializationTests.cs | 113 +++++ .../JsonWorkspaceStrategyConverterTests.cs | 98 ++++ .../Common/Services/AppConfiguration.cs | 7 + .../Services/ConfigurationProviderService.cs | 349 +++++++++++---- .../Common/Services/UserSettingsService.cs | 329 ++++++++++++-- .../Manifest/ManifestDiscoveryService.cs | 2 +- .../Settings/ViewModels/SettingsViewModel.cs | 4 + .../Services/UserDataTrackerService.cs | 8 +- .../Features/Workspace/WorkspaceManager.cs | 3 +- docs/dev/constants.md | 36 +- 21 files changed, 1955 insertions(+), 169 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs index 47097cc18..4938758ab 100644 --- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs +++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs @@ -50,6 +50,40 @@ public static class DirectoryNames /// public const string Profiles = "Profiles"; + /// + /// Directory holding manifests authored by the user, alongside . + /// + public const string CustomManifests = "CustomManifests"; + + /// + /// Directory that releases up to v0.0.3 nested the manifests, tracked user data and workspace + /// metadata under. Current releases keep those entries directly in the data root. + /// + public const string LegacyContent = "Content"; + + /// + /// Directory for storing tracked user data. + /// + public const string UserData = "UserData"; + + /// + /// Directory holding the manifests of tracked user data, nested inside . + /// + /// + /// Deliberately lower-case and separate from : this is + /// the exact name written to disk, and matching case matters on case-sensitive filesystems. + /// + public const string UserDataManifests = "manifests"; + + /// + /// Directory holding backups of replaced user data files, nested inside . + /// + /// + /// Deliberately lower-case and separate from : this is the exact name + /// written to disk, and matching case matters on case-sensitive filesystems. + /// + public const string UserDataBackups = "backups"; + /// /// Directory for storing workspaces. /// diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs index 608a21f6b..1b4b2777b 100644 --- a/GenHub/GenHub.Core/Constants/FileTypes.cs +++ b/GenHub/GenHub.Core/Constants/FileTypes.cs @@ -35,6 +35,22 @@ public static class FileTypes /// public const string SettingsFileName = "settings.json"; + /// + /// Settings file name written by releases up to v0.0.3, which combined the data root with the + /// JSON extension instead of the settings file name. + /// + public const string LegacySettingsFileName = ".json"; + + /// + /// File name holding the persisted workspace metadata. + /// + public const string WorkspaceMetadataFileName = "workspaces.json"; + + /// + /// File name of the index tracking installed user data. + /// + public const string UserDataIndexFileName = "index.json"; + /// /// File extension for replay files. /// diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index e249b1e1e..2514312f7 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Security; namespace GenHub.Core.Helpers; @@ -26,6 +27,28 @@ public static class PathHelper ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + /// + /// Determines whether two paths point at the same filesystem location, normalizing both and + /// comparing them with the platform-appropriate case sensitivity. + /// + /// The first path. + /// The second path. + /// when both paths resolve to the same location. + public static bool AreSamePath(string first, string second) + { + try + { + return string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(first)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), + PathComparison); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + return string.Equals(first, second, PathComparison); + } + } + /// /// Gets the parent directory of a path, with fallback to the path itself if at drive root. /// diff --git a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs index 38b0ec736..2d7d3eff9 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs @@ -12,6 +12,10 @@ public interface IAppConfiguration /// The root application data path. string GetConfiguredDataPath(); + /// Gets the root application data path used by releases up to v0.0.3, which stored data under the roaming profile. + /// The legacy root application data path. + string GetLegacyConfiguredDataPath(); + /// Gets the default workspace path for GenHub. /// The default workspace path. string GetDefaultWorkspacePath(); diff --git a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs index 5bab7e51b..14d6f830e 100644 --- a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs +++ b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs @@ -6,27 +6,40 @@ namespace GenHub.Core.Models.Enums; /// /// Workspace preparation strategy preference. /// +/// +/// +/// The numeric values are part of the on-disk format. Releases up to v0.0.3 serialized workspace +/// metadata without an enum converter, so workspaces.json holds raw ordinals in this order; +/// they must not be reordered. Profile files are unaffected: v0.0.3 wrote the member name. +/// +/// +/// Builds of the default branch made after v0.0.3 and before this ordering was restored wrote +/// ordinals under a reordered enum, so numbers they persisted are now read as a different member +/// (0 meant HardLink there and means SymlinkOnly here). No release is affected, but such an +/// install should have its workspaces.json and profile strategies checked after upgrading. +/// +/// [JsonConverter(typeof(JsonWorkspaceStrategyConverter))] public enum WorkspaceStrategy { - /// - /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume. - /// Default strategy for new profiles. - /// - HardLink = 0, - /// /// Symlink only strategy - creates symbolic links to all files. Minimal disk usage, requires admin rights. /// - SymlinkOnly = 1, + SymlinkOnly = 0, /// /// Full copy strategy - copies all files to workspace. Maximum compatibility and isolation, highest disk usage. /// - FullCopy = 2, + FullCopy = 1, /// /// Hybrid copy/symlink strategy - copies essential files, symlinks others. Balanced disk usage and compatibility. /// - HybridCopySymlink = 3, + HybridCopySymlink = 2, + + /// + /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume. + /// Default strategy for new profiles. + /// + HardLink = 3, } diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs index 04375f0ea..ee5ed6952 100644 --- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs +++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs @@ -7,8 +7,8 @@ namespace GenHub.Core.Serialization; /// -/// Custom JSON converter for WorkspaceStrategy that supports both string and integer formats. -/// Provides backward compatibility for integer-based strategy values. +/// Custom JSON converter for WorkspaceStrategy that writes the member name and reads both string +/// and integer formats, so metadata written by releases up to v0.0.3 still deserializes. /// public class JsonWorkspaceStrategyConverter : JsonConverter { @@ -53,6 +53,6 @@ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToCon /// public override void Write(Utf8JsonWriter writer, WorkspaceStrategy value, JsonSerializerOptions options) { - writer.WriteNumberValue((int)value); + writer.WriteStringValue(value.ToString()); } } 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 7cd571b77..6a638f2ff 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -794,10 +794,35 @@ public void GetContentDirectories_WithNullUserSetting_ReturnsDefaults() // Assert Assert.Contains(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), result); - Assert.Contains(Path.Combine(appDataPath, "CustomManifests"), result); + Assert.Contains(Path.Combine(appDataPath, DirectoryNames.CustomManifests), result); Assert.True(result.Count >= 3); } + /// + /// Verifies that the default content directories follow an explicitly set application data path, + /// so local discovery scans the same root the manifests are read from and written to. + /// + [Fact] + public void GetContentDirectories_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath, ContentDirectories = [] }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetContentDirectories(); + + // Assert + Assert.Contains(Path.Combine(userPath, FileTypes.ManifestsDirectory), result); + Assert.Contains(Path.Combine(userPath, DirectoryNames.CustomManifests), result); + Assert.Equal(provider.GetManifestsPath(), result[0]); + } + /// /// Verifies that GetGitHubDiscoveryRepositories returns user setting when available. /// @@ -840,6 +865,398 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() Assert.Equal(2, result.Count); } + /// + /// Verifies that GetProfilesPath honors an explicitly set application data path. + /// + [Fact] + public void GetProfilesPath_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetProfilesPath(); + + // Assert + Assert.Equal(Path.Combine(userPath, DirectoryNames.Profiles), result); + } + + /// + /// Verifies that GetManifestsPath honors an explicitly set application data path. + /// + [Fact] + public void GetManifestsPath_WithExplicitApplicationDataPath_ReturnsOverride() + { + // Arrange + var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root"); + var userSettings = new UserSettings { ApplicationDataPath = userPath }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path"); + + var provider = CreateProvider(); + + // Act + var result = provider.GetManifestsPath(); + + // Assert + Assert.Equal(Path.Combine(userPath, FileTypes.ManifestsDirectory), result); + } + + /// + /// Verifies that the profiles and manifests paths fall back to the configured data path when no + /// application data path override is set. + /// + [Fact] + public void GetProfilesAndManifestsPath_WithoutOverride_ReturnConfiguredDataPath() + { + // Arrange + var appDataPath = "/app/data/path"; + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(appDataPath); + + var provider = CreateProvider(); + + // Act & Assert + Assert.Equal(Path.Combine(appDataPath, DirectoryNames.Profiles), provider.GetProfilesPath()); + Assert.Equal(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), provider.GetManifestsPath()); + } + + /// + /// Verifies that the legacy roaming data root is migrated into the current root while the CAS + /// pool, which still defaults to the legacy location, is left in place. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacyData_MovesTrackedEntriesAndLeavesCasPool() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("backup", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.CasPool))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that running the legacy root migration a second time leaves the migrated data alone. + /// + [Fact] + public void MigrateLegacyDataRoot_RunTwice_IsIdempotent() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + var provider = CreateProvider(); + + provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that data already present in the current root wins over the legacy copy. + /// + [Fact] + public void MigrateLegacyDataRoot_WithExistingData_DoesNotOverwriteNewRoot() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + Directory.CreateDirectory(Path.Combine(newRoot, DirectoryNames.Profiles)); + File.WriteAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"), "current-profile"); + File.WriteAllText(Path.Combine(newRoot, FileTypes.SettingsFileName), "current-settings"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("current-profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("current-settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that a missing legacy root does not create the current root. + /// + [Fact] + public void MigrateLegacyDataRoot_WithoutLegacyRoot_DoesNothing() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + Directory.Delete(legacyRoot); + Directory.Delete(newRoot); + try + { + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(Directory.Exists(newRoot)); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the migration is skipped when both roots resolve to the same directory. + /// + [Fact] + public void MigrateLegacyDataRoot_WithIdenticalRoots_DoesNothing() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, Path.Combine(legacyRoot, "."), Path.Combine(legacyRoot, ".")); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("settings", File.ReadAllText(Path.Combine(legacyRoot, FileTypes.SettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the migration leaves nothing behind in the legacy root, so a regression from a + /// move to a copy is caught rather than passing every positive assertion. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacyData_RemovesTheLegacySources() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.Profiles))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory))); + Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.UserData))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies the steady state after a successful migration: a legacy root that still holds the CAS + /// pool, but none of the migrated entries, is left completely alone. + /// + [Fact] + public void MigrateLegacyDataRoot_WithoutLegacyEntries_LeavesBothRootsAlone() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + Directory.Delete(newRoot); + try + { + WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.False(Directory.Exists(newRoot)); + Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the sub-layout releases up to v0.0.3 wrote, which nested the manifests, tracked + /// user data and workspace metadata under a Content directory, is flattened into the data root. + /// + [Fact] + public void MigrateLegacyDataRoot_WithContentSubLayout_FlattensIntoDataRoot() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + var legacyContent = Path.Combine(legacyRoot, DirectoryNames.LegacyContent); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteFile(Path.Combine(legacyContent, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteFile(Path.Combine(legacyContent, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index"); + WriteFile(Path.Combine(legacyContent, FileTypes.WorkspaceMetadataFileName), "workspaces"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON + /// extension rather than the settings file name, is migrated under the current name. + /// + [Fact] + public void MigrateLegacyDataRoot_WithLegacySettingsFileName_MigratesUnderCurrentName() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "settings"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that a settings file already under the current name wins over the v0.0.3 one. + /// + [Fact] + public void MigrateLegacyDataRoot_WithBothSettingsFileNames_PrefersTheCurrentName() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + try + { + WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "current"); + WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "older"); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot); + + Assert.Equal("current", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Verifies that the data consumers read through the application data path lands in the override + /// root while the settings file, which is resolved from the configured root, lands there instead. + /// + [Fact] + public void MigrateLegacyDataRoot_WithSeparateDataAndSettingsRoots_SplitsTheDestinations() + { + var (legacyRoot, newRoot) = CreateMigrationRoots(); + var overrideRoot = Path.Combine(Path.GetDirectoryName(newRoot)!, "relocated"); + try + { + SeedLegacyRoot(legacyRoot); + + CreateProvider().MigrateLegacyDataRoot(legacyRoot, overrideRoot, newRoot); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.Profiles, "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.ManifestsDirectory, "content.manifest.json"))); + Assert.Equal("index", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.WorkspaceMetadataFileName))); + + Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(overrideRoot, FileTypes.SettingsFileName))); + Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.Profiles))); + } + finally + { + DeleteDirectories(legacyRoot, newRoot); + } + } + + /// + /// Creates a fresh legacy and current data root pair under the temp directory. + /// + /// The legacy and current root paths. + private static (string LegacyRoot, string NewRoot) CreateMigrationRoots() + { + var testRoot = Path.Combine(Path.GetTempPath(), $"genhub-migration-{Guid.NewGuid():N}"); + var legacyRoot = Path.Combine(testRoot, "roaming"); + var newRoot = Path.Combine(testRoot, "local"); + Directory.CreateDirectory(legacyRoot); + Directory.CreateDirectory(newRoot); + return (legacyRoot, newRoot); + } + + /// + /// Populates a legacy data root with the entries an alpha-3 install would contain. + /// + /// The legacy data root to populate. + private static void SeedLegacyRoot(string legacyRoot) + { + WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteFile(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak"), "backup"); + WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "settings"); + WriteFile(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces"); + WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas"); + } + + private static void WriteFile(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private static void DeleteDirectories(params string[] paths) + { + foreach (var path in paths.Select(Path.GetDirectoryName).Where(path => !string.IsNullOrEmpty(path)).Distinct()) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path!, true); + } + } + catch (IOException) + { + } + } + } + /// /// Creates a ConfigurationProviderService instance for testing. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs new file mode 100644 index 000000000..9553e125f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs @@ -0,0 +1,402 @@ +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Enums; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Covers the first launch after upgrading from a release that kept its data under the roaming +/// profile. +/// +/// loads in its own constructor and resolves the settings path +/// straight from , so it runs before +/// has had any chance to migrate the legacy root. Left +/// alone it would start from defaults, and the first save of the session would then write those +/// defaults over the freshly migrated settings file, permanently destroying the user's settings. +/// +/// +public class LegacyRootUpgradeTests : IDisposable +{ + private readonly string _testRoot; + private readonly string _legacyRoot; + private readonly string _newRoot; + + /// + /// Initializes a new instance of the class. + /// + public LegacyRootUpgradeTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), $"genhub-upgrade-{Guid.NewGuid():N}"); + _legacyRoot = Path.Combine(_testRoot, "roaming"); + _newRoot = Path.Combine(_testRoot, "local"); + Directory.CreateDirectory(_legacyRoot); + Directory.CreateDirectory(_newRoot); + } + + /// + /// Removes the temporary roots created for the test. + /// + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that the settings a user had before the upgrade are in effect on the first launch, + /// without waiting for a restart. + /// + [Fact] + public void FirstLaunch_WithLegacySettings_LoadsLegacyValues() + { + WriteLegacySettings(""" + { + "theme": "Light", + "maxConcurrentDownloads": 7, + "defaultWorkspaceStrategy": "SymlinkOnly" + } + """); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Light", settings.Theme); + Assert.Equal(7, settings.MaxConcurrentDownloads); + Assert.Equal(WorkspaceStrategy.SymlinkOnly, settings.DefaultWorkspaceStrategy); + } + + /// + /// Verifies the exact sequence that destroyed user settings: a first-launch load, the legacy + /// root migration moving the settings file into the new root, and then a save during that same + /// session. The saved file must still carry the user's values, not defaults. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task FirstLaunch_ThenMigrationThenSave_PreservesLegacyValuesAsync() + { + WriteLegacySettings(""" + { + "theme": "Light", + "maxConcurrentDownloads": 7 + } + """); + + var appConfig = CreateAppConfig(); + var settingsService = CreateSettingsService(appConfig); + var provider = new ConfigurationProviderService( + appConfig, + settingsService, + Mock.Of>()); + + // Triggers the legacy root migration, which moves settings.json into the new root. + provider.GetApplicationDataPath(); + Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + + settingsService.Update(settings => settings.WindowWidth = 1440.0); + await settingsService.SaveAsync(); + + var persisted = CreateSettingsService(appConfig).Get(); + Assert.Equal("Light", persisted.Theme); + Assert.Equal(7, persisted.MaxConcurrentDownloads); + Assert.Equal(1440.0, persisted.WindowWidth); + } + + /// + /// Verifies that an application data path override carried over from the legacy settings is + /// honored on the first launch rather than after a restart. + /// + [Fact] + public void FirstLaunch_WithLegacyApplicationDataPathOverride_HonorsOverride() + { + var overridePath = Path.Combine(_testRoot, "relocated"); + Directory.CreateDirectory(overridePath); + var escapedOverridePath = overridePath.Replace("\\", "\\\\"); + WriteLegacySettings($$""" + { + "applicationDataPath": "{{escapedOverridePath}}" + } + """); + + var appConfig = CreateAppConfig(); + var provider = new ConfigurationProviderService( + appConfig, + CreateSettingsService(appConfig), + Mock.Of>()); + + Assert.Equal(overridePath, provider.GetApplicationDataPath()); + Assert.Equal(Path.Combine(overridePath, DirectoryNames.Profiles), provider.GetProfilesPath()); + Assert.Equal(Path.Combine(overridePath, FileTypes.ManifestsDirectory), provider.GetManifestsPath()); + } + + /// + /// Verifies that the migration puts the profiles where + /// resolves them when an application data path override is in effect, rather than in the + /// configured root the app would never look at. + /// + [Fact] + public void FirstLaunch_WithOverride_MigratesDataIntoTheRootTheAppReadsFrom() + { + var overridePath = Path.Combine(_testRoot, "relocated"); + WriteLegacySettings($$""" + { + "applicationDataPath": "{{overridePath.Replace("\\", "\\\\")}}" + } + """); + SeedLegacyDataDirectories(); + + var appConfig = CreateAppConfig(); + var provider = new ConfigurationProviderService( + appConfig, + CreateSettingsService(appConfig), + Mock.Of>()); + + Assert.Equal("profile", File.ReadAllText(Path.Combine(provider.GetProfilesPath(), "profile.json"))); + Assert.Equal("manifest", File.ReadAllText(Path.Combine(provider.GetManifestsPath(), "content.manifest.json"))); + Assert.Equal("workspaces", File.ReadAllText(Path.Combine(provider.GetApplicationDataPath(), FileTypes.WorkspaceMetadataFileName))); + + Assert.False(Directory.Exists(Path.Combine(_newRoot, DirectoryNames.Profiles))); + Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON + /// extension rather than the settings file name, is still picked up on the first launch. + /// + [Fact] + public void FirstLaunch_WithV003SettingsFileName_LoadsLegacyValues() + { + var legacyJson = """ + { "theme": "Light", "maxConcurrentDownloads": 7 } + """; + File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.LegacySettingsFileName), legacyJson); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Light", settings.Theme); + Assert.Equal(7, settings.MaxConcurrentDownloads); + } + + /// + /// Verifies that a normalization failure, which used to reset the settings to defaults while the + /// settings path still pointed at the user's file, keeps the loaded values instead. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task FirstLaunch_WhenNormalizationThrows_KeepsLoadedValuesAsync() + { + WriteLegacySettings(""" + { "theme": "Light", "maxConcurrentDownloads": 7 } + """); + + var appConfig = CreateAppConfigMock(); + appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(8); + appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(1); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + Assert.Equal("Light", service.Get().Theme); + + await service.SaveAsync(); + + Assert.Equal("Light", CreateSettingsService().Get().Theme); + } + + /// + /// Verifies that a failed initialization can never persist defaults over a settings file that was + /// never read successfully. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_AfterFailedInitialization_RefusesToOverwriteExistingSettingsAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var existingJson = """ + { "theme": "Light" } + """; + File.WriteAllText(settingsPath, existingJson); + + var appConfig = CreateBaseAppConfigMock(); + appConfig.Setup(config => config.GetConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied")); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Contains("Light", File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that a settings file the loader could not parse blocks the save that would replace + /// it with defaults. The failure is swallowed inside the load, so nothing reaches the outer + /// catch and the file looks like a clean load unless the load reports what it produced. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithCorruptSettingsFile_RefusesToOverwriteAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var corruptJson = "{ invalid json }"; + File.WriteAllText(settingsPath, corruptJson); + + var service = CreateSettingsService(); + Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(corruptJson, File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that a corrupt pre-upgrade settings file blocks saving as well, rather than starting + /// the session from defaults and writing them into the current root as if the upgrade had found + /// nothing to carry over. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithCorruptLegacySettingsFile_RefusesToOverwriteAsync() + { + var corruptJson = "{ invalid json }"; + WriteLegacySettings(corruptJson); + + var service = CreateSettingsService(); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(corruptJson, File.ReadAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName))); + Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a settings file which could not be opened, the case of a file locked by another + /// process or denied by permissions, blocks saving and therefore survives the session. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_WithUnreadableSettingsFile_RefusesToOverwriteAsync() + { + var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName); + var existingJson = """ + { "theme": "Light" } + """; + File.WriteAllText(settingsPath, existingJson); + + UserSettingsService service; + using (File.Open(settingsPath, System.IO.FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + service = CreateSettingsService(); + } + + await Assert.ThrowsAsync(() => service.SaveAsync()); + Assert.Equal(existingJson, File.ReadAllText(settingsPath)); + } + + /// + /// Verifies that the absence of any settings file is still a legitimate first run, so blocking + /// saves after a failed load cannot leave a fresh install unable to persist anything. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task Save_OnFirstRunWithoutAnySettingsFile_PersistsTheSettingsAsync() + { + Directory.Delete(_legacyRoot); + + var service = CreateSettingsService(); + service.Update(settings => settings.Theme = "Light"); + await service.SaveAsync(); + + Assert.Contains("Light", File.ReadAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a settings file already present in the current root wins over the legacy copy. + /// + [Fact] + public void SecondLaunch_WithSettingsInNewRoot_IgnoresLegacyFile() + { + WriteLegacySettings(""" + { "theme": "Light" } + """); + var currentSettingsJson = """ + { "theme": "Dark" } + """; + File.WriteAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName), currentSettingsJson); + + var settings = CreateSettingsService().Get(); + + Assert.Equal("Dark", settings.Theme); + } + + /// + /// Verifies that a fresh install, which has no legacy root at all, is unaffected. + /// + [Fact] + public void FreshInstall_WithoutLegacyRoot_UsesDefaults() + { + Directory.Delete(_legacyRoot); + + var service = CreateSettingsService(); + var settings = service.Get(); + + Assert.Equal(AppConstants.DefaultThemeName, settings.Theme); + Assert.False(settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath))); + Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName))); + } + + /// + /// Verifies that a failure while looking for the pre-upgrade settings cannot stop startup. + /// + [Fact] + public void FirstLaunch_WhenLegacyLookupThrows_FallsBackToDefaults() + { + var appConfig = CreateAppConfigMock(); + appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied")); + + var service = new UserSettingsService(Mock.Of>(), appConfig.Object); + + Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme); + } + + private static Mock CreateBaseAppConfigMock() + { + var appConfig = new Mock(); + appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(1); + appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(8); + appConfig.Setup(config => config.GetMinDownloadTimeoutSeconds()).Returns(30); + appConfig.Setup(config => config.GetMaxDownloadTimeoutSeconds()).Returns(600); + appConfig.Setup(config => config.GetMinDownloadBufferSizeBytes()).Returns(4096); + appConfig.Setup(config => config.GetMaxDownloadBufferSizeBytes()).Returns(1048576); + return appConfig; + } + + private Mock CreateAppConfigMock() + { + var appConfig = CreateBaseAppConfigMock(); + appConfig.Setup(config => config.GetConfiguredDataPath()).Returns(_newRoot); + appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Returns(_legacyRoot); + return appConfig; + } + + private IAppConfiguration CreateAppConfig() => CreateAppConfigMock().Object; + + private UserSettingsService CreateSettingsService(IAppConfiguration? appConfig = null) => + new(Mock.Of>(), appConfig ?? CreateAppConfig()); + + private void WriteLegacySettings(string json) => + File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName), json); + + private void SeedLegacyDataDirectories() + { + WriteLegacyFile(Path.Combine(_legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile"); + WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest"); + WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces"); + } + + private void WriteLegacyFile(string path, string content) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 1e105e2ef..f5b451106 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs @@ -233,10 +233,7 @@ public async Task SaveAsync_CreatesDirectoryIfNotExistsAsync() var nestedPath = Path.Combine(_tempDirectory, "nested", "path"); var settingsPath = Path.Combine(nestedPath, FileTypes.JsonFileExtension); var service = CreateService(); - var settingsPathField = typeof(UserSettingsService) - .GetField("_settingsFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(settingsPathField); - settingsPathField.SetValue(service, settingsPath); + service.AdoptSettingsFile(settingsPath); await service.SaveAsync(); Assert.True(Directory.Exists(nestedPath)); Assert.True(File.Exists(settingsPath)); @@ -264,10 +261,7 @@ public async Task SaveAsync_WithLongPath_CreatesNestedDirectoriesAsync() var settingsPath = Path.Combine(deepPath, FileTypes.JsonFileExtension); var service = CreateService(); - var settingsPathField = typeof(UserSettingsService) - .GetField("_settingsFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - Assert.NotNull(settingsPathField); - settingsPathField.SetValue(service, settingsPath); + service.AdoptSettingsFile(settingsPath); // Act await service.SaveAsync(); @@ -385,6 +379,177 @@ public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved() Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes); } + /// + /// Verifies that pointing the settings file at a file that already holds settings refuses the + /// save instead of replacing that file with values read from a different one, and that the + /// edits being saved survive the refusal. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task Update_WhenRepointedAtExistingSettingsFile_RefusesToOverwriteItAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var otherPath = Path.Combine(_tempDirectory, "backup.json"); + var otherJson = """{ "theme": "Light", "maxConcurrentDownloads": 7 }"""; + File.WriteAllText(currentPath, """{ "theme": "Dark" }"""); + File.WriteAllText(otherPath, otherJson); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + service.Update(settings => + { + settings.WorkspacePath = "/edited"; + settings.SettingsFilePath = otherPath; + }); + + await Assert.ThrowsAsync(() => service.SaveAsync()); + + Assert.Equal(otherJson, File.ReadAllText(otherPath)); + Assert.Equal("/edited", service.Get().WorkspacePath); + } + + /// + /// Verifies that the same re-point through the combined update-and-save entry point reports + /// failure rather than overwriting the file it was pointed at. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task TryUpdateAndSaveAsync_WhenRepointedAtExistingSettingsFile_FailsWithoutOverwritingItAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var otherPath = Path.Combine(_tempDirectory, "backup.json"); + var otherJson = """{ "theme": "Light", "maxConcurrentDownloads": 7 }"""; + File.WriteAllText(currentPath, """{ "theme": "Dark" }"""); + File.WriteAllText(otherPath, otherJson); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + var saved = await service.TryUpdateAndSaveAsync(settings => + { + settings.SettingsFilePath = otherPath; + return true; + }); + + Assert.False(saved); + Assert.Equal(otherJson, File.ReadAllText(otherPath)); + } + + /// + /// Verifies that relocating the settings to a path that holds nothing is still honoured, since + /// there is nothing there for the save to destroy. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task Update_WhenRepointedAtUnusedPath_SavesTheEditsThereAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var newPath = Path.Combine(_tempDirectory, "moved", FileTypes.SettingsFileName); + File.WriteAllText(currentPath, """{ "theme": "Dark" }"""); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + service.Update(settings => + { + settings.Theme = "Light"; + settings.SettingsFilePath = newPath; + }); + + await service.SaveAsync(); + + Assert.Contains("Light", File.ReadAllText(newPath)); + } + + /// + /// Verifies that a refused re-point is recoverable by pointing back at the file the settings + /// were read from, so the refusal cannot strand the session. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task Update_AfterRefusedRepoint_SavesAgainOncePointedBackAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var otherPath = Path.Combine(_tempDirectory, "backup.json"); + var otherJson = """{ "theme": "Light" }"""; + File.WriteAllText(currentPath, """{ "theme": "Dark" }"""); + File.WriteAllText(otherPath, otherJson); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + service.Update(settings => + { + settings.WorkspacePath = "/edited"; + settings.SettingsFilePath = otherPath; + }); + await Assert.ThrowsAsync(() => service.SaveAsync()); + + service.Update(settings => settings.SettingsFilePath = currentPath); + await service.SaveAsync(); + + Assert.Contains("/edited", File.ReadAllText(currentPath)); + Assert.Equal(otherJson, File.ReadAllText(otherPath)); + } + + /// + /// Verifies that a refused re-point is also recoverable by clearing the path it was refused + /// for, so the refusal lasts exactly as long as the file it protects. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task Update_AfterRefusedRepoint_SavesOnceTheConflictingFileIsGoneAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var otherPath = Path.Combine(_tempDirectory, "backup.json"); + File.WriteAllText(currentPath, """{ "theme": "Dark" }"""); + File.WriteAllText(otherPath, """{ "theme": "Light" }"""); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + service.Update(settings => + { + settings.WorkspacePath = "/edited"; + settings.SettingsFilePath = otherPath; + }); + await Assert.ThrowsAsync(() => service.SaveAsync()); + + File.Delete(otherPath); + service.Update(settings => settings.SettingsFilePath = otherPath); + await service.SaveAsync(); + + Assert.Contains("/edited", File.ReadAllText(otherPath)); + } + + /// + /// Verifies that the ordinary save, where the settings name the very file they were read from, + /// is unaffected by the re-point check. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task Update_WhenTheSettingsNameTheFileTheyCameFrom_SavesAsync() + { + var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + File.WriteAllText( + currentPath, + $$"""{ "theme": "Dark", "settingsFilePath": {{JsonSerializer.Serialize(currentPath)}} }"""); + + var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath); + service.Update(settings => settings.Theme = "Light"); + + await service.SaveAsync(); + + Assert.Contains("Light", File.ReadAllText(currentPath)); + } + + /// + /// Verifies that a first run, which has no settings file at all, still persists its settings. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task SaveAsync_OnFirstRunWithoutAnExistingFile_PersistsTheSettingsAsync() + { + var settingsPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName); + var service = CreateServiceWithPath(settingsPath); + + service.Update(settings => settings.Theme = "Light"); + await service.SaveAsync(); + + Assert.Contains("Light", File.ReadAllText(settingsPath)); + } + private static IAppConfiguration CreateAppConfigMock() { var appConfig = new Mock(); @@ -448,5 +613,7 @@ public TestableUserSettingsService(ILogger logger, IAppConf // We then set the path, which will load from the file if it exists. SetSettingsFilePath(settingsFilePath); } + + public void AdoptSettingsFile(string settingsFilePath) => SetSettingsFilePath(settingsFilePath); } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs index 7c5c2afe9..a0aaa1275 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs @@ -34,6 +34,7 @@ public class ApplicationDataPathConventionTests { // The implementation of the convention itself has to start somewhere. ["ConfigurationProviderService.cs"] = "Defines the canonical path.", + ["AppConfiguration.cs"] = "Resolves the legacy roaming root the upgrade migration reads from.", ["UserSettingsService.cs"] = "Loads the settings file that stores the override; cannot depend on it.", // Displays the built-in default next to the user's override in the UI. diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs index b19efd094..39cf3009d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/GameProfileDeserializationTests.cs @@ -8,9 +8,11 @@ namespace GenHub.Tests.Core.Models; /// /// Tests to verify that GameProfile correctly applies default values during deserialization. -/// This addresses the bug where WorkspaceStrategy was defaulting to SymlinkOnly (enum default 0) -/// WorkspaceStrategyJsonConverter correctly handles the null/missing property, allowing -/// services to apply the global default fallback. +/// The numeric values exercised here are not the profile format of any release: v0.0.3 serialized +/// profiles with a string enum converter, so it wrote member names. Numbers only reach a profile +/// file from v0.0.2 and older, or from a build of the default branch made while the enum was +/// reordered. Pinning the mapping is a deliberate decision, because the ordinals below are the ones +/// v0.0.3 wrote into workspaces.json and the two formats have to agree. /// public class GameProfileDeserializationTests { @@ -43,12 +45,12 @@ public void Deserialize_ProfileWithoutWorkspaceStrategy_ShouldHaveNullStrategy() [Fact] public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() { - // Arrange - JSON with explicit SymlinkOnly (1) + // Arrange - JSON with explicit SymlinkOnly (0) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 1 + "WorkspaceStrategy": 0 } """; @@ -58,7 +60,6 @@ public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() // Assert Assert.NotNull(profile); - // Should NOT be overridden to HardLink anymore Assert.Equal(WorkspaceStrategy.SymlinkOnly, profile.WorkspaceStrategy); } @@ -68,12 +69,12 @@ public void Deserialize_ProfileWithSymlinkOnly_ShouldPreserveSymlinkOnly() [Fact] public void Deserialize_ProfileWithExplicitHardLink_ShouldPreserveHardLink() { - // Arrange - JSON with explicit HardLink (0) + // Arrange - JSON with explicit HardLink (3) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 0 + "WorkspaceStrategy": 3 } """; @@ -91,12 +92,12 @@ public void Deserialize_ProfileWithExplicitHardLink_ShouldPreserveHardLink() [Fact] public void Deserialize_ProfileWithCopyStrategy_ShouldPreserveCopy() { - // Arrange - JSON with explicit Copy strategy (2) + // Arrange - JSON with explicit Copy strategy (1) var json = """ { "Id": "test_profile", "Name": "Test Profile", - "WorkspaceStrategy": 2 + "WorkspaceStrategy": 1 } """; @@ -227,4 +228,39 @@ public void Deserialize_ProfileWithStringEnum_ShouldParseCorrectly() Assert.NotNull(profile); Assert.Equal(WorkspaceStrategy.HardLink, profile.WorkspaceStrategy); } + + /// + /// Verifies that a profile persisted by releases up to v0.0.3, which wrote the strategy as a + /// name using the repository serializer options, still resolves to the same strategy. + /// + /// The strategy name persisted in the profile file. + /// The strategy the profile must resolve to. + [Theory] + [InlineData("SymlinkOnly", WorkspaceStrategy.SymlinkOnly)] + [InlineData("FullCopy", WorkspaceStrategy.FullCopy)] + [InlineData("HybridCopySymlink", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("HardLink", WorkspaceStrategy.HardLink)] + public void Deserialize_LegacyProfileFile_ShouldPreserveStrategy(string strategyName, WorkspaceStrategy expected) + { + // Arrange - profile file as written by GameProfileRepository before the move to a string enum + var json = $$""" + { + "id": "test_profile", + "name": "Test Profile", + "workspaceStrategy": "{{strategyName}}" + } + """; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + }; + + // Act + var profile = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(profile); + Assert.Equal(expected, profile.WorkspaceStrategy); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs new file mode 100644 index 000000000..2b7e9c226 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Models/Workspace/WorkspaceMetadataDeserializationTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Workspace; +using Xunit; + +namespace GenHub.Tests.Core.Models.Workspace; + +/// +/// Tests that workspace metadata written by releases up to v0.0.3 still resolves to the strategy it +/// was persisted with. A mismatch between the persisted strategy and the profile strategy makes +/// WorkspaceManager discard and rebuild the workspace. +/// +public class WorkspaceMetadataDeserializationTests +{ + private static readonly JsonSerializerOptions MetadataOptions = new() { WriteIndented = true }; + + /// + /// Verifies that the raw ordinals stored in workspaces.json map back to their original strategies. + /// + [Fact] + public void Deserialize_LegacyWorkspacesFile_MapsOrdinalsToOriginalStrategies() + { + var json = """ + [ + { + "Id": "symlink-workspace", + "WorkspacePath": "/data/workspaces/symlink-workspace", + "GameClientId": "generals-zh", + "Strategy": 0, + "IsPrepared": true + }, + { + "Id": "fullcopy-workspace", + "WorkspacePath": "/data/workspaces/fullcopy-workspace", + "GameClientId": "generals-zh", + "Strategy": 1, + "IsPrepared": true + }, + { + "Id": "hybrid-workspace", + "WorkspacePath": "/data/workspaces/hybrid-workspace", + "GameClientId": "generals-zh", + "Strategy": 2, + "IsPrepared": true + }, + { + "Id": "hardlink-workspace", + "WorkspacePath": "/data/workspaces/hardlink-workspace", + "GameClientId": "generals-zh", + "Strategy": 3, + "IsPrepared": true + } + ] + """; + + var workspaces = JsonSerializer.Deserialize>(json, MetadataOptions); + + Assert.NotNull(workspaces); + Assert.Equal( + new[] + { + WorkspaceStrategy.SymlinkOnly, + WorkspaceStrategy.FullCopy, + WorkspaceStrategy.HybridCopySymlink, + WorkspaceStrategy.HardLink, + }, + workspaces.Select(workspace => workspace.Strategy)); + } + + /// + /// Verifies that a legacy workspace and the profile that owns it agree on the strategy, which is + /// the comparison that decides whether an existing workspace can be reused. + /// + /// The ordinal persisted in workspaces.json. + /// The strategy name persisted in the profile. + [Theory] + [InlineData(0, "SymlinkOnly")] + [InlineData(1, "FullCopy")] + [InlineData(2, "HybridCopySymlink")] + [InlineData(3, "HardLink")] + public void Deserialize_LegacyWorkspaceAndProfile_AgreeOnStrategy(int workspaceOrdinal, string profileStrategyName) + { + var workspaceJson = $$""" + { "Id": "workspace", "Strategy": {{workspaceOrdinal}} } + """; + var profileJson = $"\"{profileStrategyName}\""; + + var workspace = JsonSerializer.Deserialize(workspaceJson, MetadataOptions); + var profileStrategy = JsonSerializer.Deserialize(profileJson); + + Assert.NotNull(workspace); + Assert.Equal(profileStrategy, workspace.Strategy); + } + + /// + /// Verifies that newly written workspace metadata stores the strategy name, so a future + /// reordering of the enum cannot corrupt it. + /// + [Fact] + public void Serialize_WorkspaceMetadata_WritesStrategyName() + { + var workspaces = new List + { + new() { Id = "workspace", Strategy = WorkspaceStrategy.HardLink }, + }; + + var json = JsonSerializer.Serialize(workspaces, MetadataOptions); + + Assert.Contains("\"Strategy\": \"HardLink\"", json); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs new file mode 100644 index 000000000..13de8cc97 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Serialization/JsonWorkspaceStrategyConverterTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using GenHub.Core.Models.Enums; +using Xunit; + +namespace GenHub.Tests.Core.Serialization; + +/// +/// Tests for . +/// +public class JsonWorkspaceStrategyConverterTests +{ + /// + /// Verifies that the strategy is written as its member name rather than its ordinal. + /// + /// The strategy to serialize. + /// The expected JSON payload. + [Theory] + [InlineData(WorkspaceStrategy.SymlinkOnly, "\"SymlinkOnly\"")] + [InlineData(WorkspaceStrategy.FullCopy, "\"FullCopy\"")] + [InlineData(WorkspaceStrategy.HybridCopySymlink, "\"HybridCopySymlink\"")] + [InlineData(WorkspaceStrategy.HardLink, "\"HardLink\"")] + public void Serialize_WritesStrategyName(WorkspaceStrategy strategy, string expectedJson) + { + var json = JsonSerializer.Serialize(strategy); + + Assert.Equal(expectedJson, json); + } + + /// + /// Verifies that the ordinals written by releases up to v0.0.3 still map to the same strategies. + /// + /// The legacy numeric JSON payload. + /// The strategy the payload must resolve to. + [Theory] + [InlineData("0", WorkspaceStrategy.SymlinkOnly)] + [InlineData("1", WorkspaceStrategy.FullCopy)] + [InlineData("2", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("3", WorkspaceStrategy.HardLink)] + public void Deserialize_LegacyNumericValue_ReturnsOriginalStrategy(string json, WorkspaceStrategy expected) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(expected, result); + } + + /// + /// Verifies that string payloads are still accepted. + /// + /// The string JSON payload. + /// The strategy the payload must resolve to. + [Theory] + [InlineData("\"SymlinkOnly\"", WorkspaceStrategy.SymlinkOnly)] + [InlineData("\"FullCopy\"", WorkspaceStrategy.FullCopy)] + [InlineData("\"HybridCopySymlink\"", WorkspaceStrategy.HybridCopySymlink)] + [InlineData("\"HardLink\"", WorkspaceStrategy.HardLink)] + [InlineData("\"hardlink\"", WorkspaceStrategy.HardLink)] + public void Deserialize_StringValue_ReturnsMatchingStrategy(string json, WorkspaceStrategy expected) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(expected, result); + } + + /// + /// Verifies that a round trip preserves the strategy and produces a string payload. + /// + /// The strategy to round trip. + [Theory] + [InlineData(WorkspaceStrategy.SymlinkOnly)] + [InlineData(WorkspaceStrategy.FullCopy)] + [InlineData(WorkspaceStrategy.HybridCopySymlink)] + [InlineData(WorkspaceStrategy.HardLink)] + public void RoundTrip_PreservesStrategy(WorkspaceStrategy strategy) + { + var json = JsonSerializer.Serialize(strategy); + + using (var document = JsonDocument.Parse(json)) + { + Assert.Equal(JsonValueKind.String, document.RootElement.ValueKind); + } + + Assert.Equal(strategy, JsonSerializer.Deserialize(json)); + } + + /// + /// Verifies that unrecognised payloads fall back to the default strategy. + /// + /// The unrecognised JSON payload. + [Theory] + [InlineData("999")] + [InlineData("\"NotAStrategy\"")] + public void Deserialize_UnknownValue_ReturnsHardLink(string json) + { + var result = JsonSerializer.Deserialize(json); + + Assert.Equal(WorkspaceStrategy.HardLink, result); + } +} diff --git a/GenHub/GenHub/Common/Services/AppConfiguration.cs b/GenHub/GenHub/Common/Services/AppConfiguration.cs index 5576d14f1..42963680f 100644 --- a/GenHub/GenHub/Common/Services/AppConfiguration.cs +++ b/GenHub/GenHub/Common/Services/AppConfiguration.cs @@ -226,4 +226,11 @@ public string GetConfiguredDataPath() ? configured : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppConstants.AppName); } + + /// + /// Gets the application data path used by releases up to v0.0.3, which stored data under the roaming profile. + /// + /// The legacy application data path as a string. + public string GetLegacyConfiguredDataPath() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppConstants.AppName); } \ No newline at end of file diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index db8eb5745..e103e2964 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; @@ -20,11 +22,41 @@ public class ConfigurationProviderService( IUserSettingsService userSettings, ILogger logger) : IConfigurationProviderService { + private static readonly string[] LegacyRootDirectories = + [ + DirectoryNames.Profiles, + FileTypes.ManifestsDirectory, + DirectoryNames.UserData, + ]; + + private static readonly string[] LegacySettingsFileNames = + [ + FileTypes.SettingsFileName, + FileTypes.LegacySettingsFileName, + ]; + + /// + /// The sub-paths of the legacy data root a tracked entry may sit in, most recent layout first so + /// that a newer copy wins over an older one when both are present. + /// + private static readonly string[] LegacyRootLayouts = + [ + string.Empty, + DirectoryNames.LegacyContent, + ]; + private readonly IAppConfiguration _appConfig = appConfig ?? throw new ArgumentNullException(nameof(appConfig)); private readonly IUserSettingsService _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly object _migrationLock = new(); - private bool _migrated; + + /// + /// Set once the migration has finished. Volatile because the fast path in + /// reads it outside : without + /// the release/acquire pair a second thread could observe the flag on a weakly ordered + /// architecture and read profiles or manifests before the moves that produced them are visible. + /// + private volatile bool _migrated; /// public string GetWorkspacePath() @@ -259,10 +291,11 @@ public List GetContentDirectories() return settings.ContentDirectories; } + var dataRoot = GetApplicationDataPath(); return [ - Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.ManifestsDirectory), - Path.Combine(_appConfig.GetConfiguredDataPath(), "CustomManifests"), + Path.Combine(dataRoot, FileTypes.ManifestsDirectory), + Path.Combine(dataRoot, DirectoryNames.CustomManifests), Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Command and Conquer Generals Zero Hour Data", @@ -288,37 +321,18 @@ public List GetGitHubDiscoveryRepositories() /// public string GetApplicationDataPath() { - if (!_migrated) - { - lock (_migrationLock) - { - if (!_migrated) - { - // Double-check - MigrateContentDirectory(); - _migrated = true; - } - } - } - - var settings = _userSettings.Get(); - if (settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath)) && - !string.IsNullOrWhiteSpace(settings.ApplicationDataPath)) - { - return settings.ApplicationDataPath; - } - - return _appConfig.GetConfiguredDataPath(); + EnsureLegacyDataMigrated(); + return ResolveApplicationDataPath(); } /// public string GetRootAppDataPath() => _appConfig.GetConfiguredDataPath(); /// - public string GetProfilesPath() => Path.Combine(_appConfig.GetConfiguredDataPath(), DirectoryNames.Profiles); + public string GetProfilesPath() => Path.Combine(GetApplicationDataPath(), DirectoryNames.Profiles); /// - public string GetManifestsPath() => Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.ManifestsDirectory); + public string GetManifestsPath() => Path.Combine(GetApplicationDataPath(), FileTypes.ManifestsDirectory); /// /// @@ -357,99 +371,280 @@ public string GetLogsPath() DirectoryNames.Logs.ToLowerInvariant()); } - private void MigrateContentDirectory() + /// + /// Moves the data written by releases that stored everything under the roaming application data + /// folder into the current data root, so upgrading users keep their profiles, manifests, tracked + /// user data, workspace metadata and settings. + /// + /// The roaming data root used before the move to local application data. + /// The root every consumer of reads from. + /// The root the settings file is read from and written to. + /// + /// + /// The two destinations differ deliberately. Profiles, manifests, tracked user data and the + /// workspace metadata are all resolved through , so they have + /// to follow an explicitly configured override; + /// moving them into the configured root instead would leave them where nothing ever looks. The + /// settings file is resolved straight from + /// and therefore has to land there. + /// + /// + /// Releases up to v0.0.3 nested the manifests, tracked user data and workspace metadata under a + /// Content directory, so both that layout and the flat one are probed and flattened into + /// the destination. Data that a v0.0.3 install kept outside the legacy root, because an + /// override pointed elsewhere, is out of scope and + /// stays where it is. + /// + /// + /// The CAS pool is deliberately excluded: still defaults to the + /// legacy location, so moving the pool would orphan it. + /// + /// + internal void MigrateLegacyDataRoot(string legacyRoot, string dataRoot, string settingsRoot) { - try + if (!Directory.Exists(legacyRoot)) { - var rootPath = _appConfig.GetConfiguredDataPath(); - var contentPath = Path.Combine(rootPath, "Content"); + return; + } - if (!Directory.Exists(contentPath)) - { - return; - } + var directories = ResolveLegacyDirectories(legacyRoot, dataRoot); + var files = ResolveLegacyFiles(legacyRoot, dataRoot, settingsRoot); - _logger.LogInformation("Migrating content from {ContentPath} to root {RootPath}", contentPath, rootPath); + if (directories.Count == 0 && files.Count == 0) + { + return; + } - // 1. Move Manifests - MigrateDirectory(Path.Combine(contentPath, "Manifests"), Path.Combine(rootPath, "Manifests")); + _logger.LogInformation( + "Migrating legacy data root {LegacyRoot} into {DataRoot}, settings into {SettingsRoot}", + legacyRoot, + dataRoot, + settingsRoot); - // 2. Move UserData - MigrateDirectory(Path.Combine(contentPath, "UserData"), Path.Combine(rootPath, "UserData")); + if (directories.Count > 0) + { + Directory.CreateDirectory(dataRoot); + } - // 3. Move workspaces.json - var sourceWorkspaces = Path.Combine(contentPath, "workspaces.json"); - var destWorkspaces = Path.Combine(rootPath, "workspaces.json"); - if (File.Exists(sourceWorkspaces)) + foreach (var (source, destination) in directories) + { + try { - if (!File.Exists(destWorkspaces)) - { - File.Move(sourceWorkspaces, destWorkspaces); - _logger.LogInformation("Moved workspaces.json to root"); - } - else - { - _logger.LogWarning("workspaces.json already exists in root, keeping original in Content (backup)"); - } + MigrateDirectory(source, destination); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy directory {Source}", source); } + } - // 4. Try to delete Content if empty + foreach (var (source, destination) in files) + { try { - if (Directory.GetFiles(contentPath).Length == 0 && Directory.GetDirectories(contentPath).Length == 0) - { - Directory.Delete(contentPath); - _logger.LogInformation("Deleted empty Content directory"); - } + MigrateFile(source, destination); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy file {Source}", source); } - catch + } + } + + private static List<(string Source, string Destination)> ResolveLegacyDirectories(string legacyRoot, string dataRoot) => + LegacyRootDirectories + .SelectMany( + _ => LegacyRootLayouts, + (name, layout) => (Source: Path.Combine(legacyRoot, layout, name), Destination: Path.Combine(dataRoot, name))) + .Where(entry => Directory.Exists(entry.Source) && !PathHelper.AreSamePath(entry.Source, entry.Destination)) + .ToList(); + + private static List<(string Source, string Destination)> ResolveLegacyFiles(string legacyRoot, string dataRoot, string settingsRoot) => + LegacyRootLayouts + .Select(layout => ( + Source: Path.Combine(legacyRoot, layout, FileTypes.WorkspaceMetadataFileName), + Destination: Path.Combine(dataRoot, FileTypes.WorkspaceMetadataFileName))) + .Concat(LegacySettingsFileNames + .Select(name => ( + Source: Path.Combine(legacyRoot, name), + Destination: Path.Combine(settingsRoot, FileTypes.SettingsFileName)))) + .Where(entry => File.Exists(entry.Source) && !PathHelper.AreSamePath(entry.Source, entry.Destination)) + .ToList(); + + private void EnsureLegacyDataMigrated() + { + if (_migrated) + { + return; + } + + lock (_migrationLock) + { + if (_migrated) { - // Ignore if not empty + return; } + + MigrateLegacyDataRoot(); + MigrateContentDirectory(); + _migrated = true; + } + } + + /// + /// Resolves the effective data root without triggering the legacy migration, so the migration + /// itself can ask where the app will read from. + /// + /// The explicitly configured override when set, otherwise the configured data root. + private string ResolveApplicationDataPath() + { + var settings = _userSettings.Get(); + return settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath)) && + !string.IsNullOrWhiteSpace(settings.ApplicationDataPath) + ? settings.ApplicationDataPath + : _appConfig.GetConfiguredDataPath(); + } + + private void MigrateLegacyDataRoot() + { + try + { + MigrateLegacyDataRoot( + _appConfig.GetLegacyConfiguredDataPath(), + ResolveApplicationDataPath(), + _appConfig.GetConfiguredDataPath()); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate legacy data root"); + } + } + + private void MigrateContentDirectory() + { + try + { + var rootPath = ResolveApplicationDataPath(); + var contentPath = Path.Combine(rootPath, DirectoryNames.LegacyContent); + + if (!Directory.Exists(contentPath)) + { + return; + } + + _logger.LogInformation("Migrating content from {ContentPath} to root {RootPath}", contentPath, rootPath); + + MigrateDirectory(Path.Combine(contentPath, FileTypes.ManifestsDirectory), Path.Combine(rootPath, FileTypes.ManifestsDirectory)); + MigrateDirectory(Path.Combine(contentPath, DirectoryNames.UserData), Path.Combine(rootPath, DirectoryNames.UserData)); + MigrateFile( + Path.Combine(contentPath, FileTypes.WorkspaceMetadataFileName), + Path.Combine(rootPath, FileTypes.WorkspaceMetadataFileName)); + + TryDeleteEmptyDirectory(contentPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) { _logger.LogError(ex, "Failed to migrate Content directory"); } } + private void TryDeleteEmptyDirectory(string path) + { + try + { + if (!Directory.EnumerateFileSystemEntries(path).Any()) + { + Directory.Delete(path); + _logger.LogInformation("Deleted empty directory {Path}", path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogDebug(ex, "Could not delete {Path} after migration", path); + } + } + private void MigrateDirectory(string sourceDir, string destDir) { - if (!Directory.Exists(sourceDir)) return; + if (!Directory.Exists(sourceDir)) + { + return; + } if (!Directory.Exists(destDir)) { - Directory.Move(sourceDir, destDir); - _logger.LogInformation("Moved {Source} to {Dest}", sourceDir, destDir); - return; + try + { + Directory.Move(sourceDir, destDir); + _logger.LogInformation("Moved {Source} to {Dest}", sourceDir, destDir); + return; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Could not move {Source} to {Dest} directly, falling back to per-entry migration", sourceDir, destDir); + Directory.CreateDirectory(destDir); + } } - // Destination exists, move content foreach (var file in Directory.GetFiles(sourceDir)) { - var destFile = Path.Combine(destDir, Path.GetFileName(file)); - if (!File.Exists(destFile)) + try + { + MigrateFile(file, Path.Combine(destDir, Path.GetFileName(file))); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) { - File.Move(file, destFile); + _logger.LogError(ex, "Failed to migrate {Source}, leaving it in place", file); } } foreach (var subDir in Directory.GetDirectories(sourceDir)) { - var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir)); - MigrateDirectory(subDir, destSubDir); + try + { + MigrateDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir))); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogError(ex, "Failed to migrate {Source}, leaving it in place", subDir); + } + } + + TryDeleteEmptyDirectory(sourceDir); + } + + private void MigrateFile(string sourceFile, string destFile) + { + if (!File.Exists(sourceFile)) + { + return; + } + + if (File.Exists(destFile)) + { + _logger.LogInformation("Skipping {Source}, {Dest} already exists", sourceFile, destFile); + return; + } + + var destDir = Path.GetDirectoryName(destFile); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); } - // Try delete source if empty try { - if (!Directory.EnumerateFileSystemEntries(sourceDir).Any()) - { - Directory.Delete(sourceDir); - } + File.Move(sourceFile, destFile); } - catch + catch (IOException ex) { + // File.Move cannot cross volumes on every platform; copy and only drop the source once + // the copy is on disk so a failure can never lose the file. + _logger.LogWarning(ex, "Could not move {Source} to {Dest} directly, copying instead", sourceFile, destFile); + File.Copy(sourceFile, destFile, overwrite: false); + File.Delete(sourceFile); } + + _logger.LogInformation("Moved {Source} to {Dest}", sourceFile, destFile); } } diff --git a/GenHub/GenHub/Common/Services/UserSettingsService.cs b/GenHub/GenHub/Common/Services/UserSettingsService.cs index ab3bacb67..953cce947 100644 --- a/GenHub/GenHub/Common/Services/UserSettingsService.cs +++ b/GenHub/GenHub/Common/Services/UserSettingsService.cs @@ -1,10 +1,13 @@ using System; using System.IO; +using System.Linq; +using System.Security; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; using Microsoft.Extensions.Logging; @@ -27,10 +30,21 @@ public class UserSettingsService : IUserSettingsService Converters = { new JsonStringEnumConverter() }, }; + /// + /// The settings file names to look for in the pre-upgrade data root, most recent first. + /// Releases up to v0.0.3 combined the data root with the JSON extension rather than the settings + /// file name, so their settings file is literally named .json. + /// + private static readonly string[] LegacySettingsFileNames = + [ + FileTypes.SettingsFileName, + FileTypes.LegacySettingsFileName, + ]; + private readonly ILogger _logger; private readonly IAppConfiguration _appConfig; private readonly object _lock = new(); - private string _settingsFilePath = string.Empty; + private SettingsFileTarget _target = SettingsFileTarget.Unverified(string.Empty); private UserSettings _settings = new(); /// @@ -48,7 +62,11 @@ public UserSettingsService(ILogger logger, IAppConfiguratio /// /// Logger instance. /// Application configuration service. - /// Whether to perform normal initialization. + /// + /// Whether to read the settings from disk. When the service starts from + /// defaults with no file it is allowed to write, until + /// establishes one. + /// protected UserSettingsService(ILogger logger, IAppConfiguration appConfig, bool initialize) { _logger = logger; @@ -58,12 +76,29 @@ protected UserSettingsService(ILogger logger, IAppConfigura { InitializeSettings(); } - else - { - // For testing - set defaults but don't load from file - _settingsFilePath = string.Empty; - _settings = new UserSettings(); - } + } + + /// + /// What reading a settings file produced, so the caller can tell the absence of a settings file + /// apart from a settings file it could not read. + /// + private enum SettingsLoadOutcome + { + /// + /// No settings were there to read, so starting from defaults loses nothing. + /// + Absent, + + /// + /// The settings were read from the file. + /// + Loaded, + + /// + /// Settings exist but could not be read, so the defaults returned alongside this outcome + /// must never be persisted over them. + /// + Failed, } /// @@ -90,13 +125,7 @@ public void Update(Action applyChanges) // Only update internal state if no exception occurred _settings = settingsCopy; - - // If the settings file path was changed, update the internal field - if (!string.IsNullOrWhiteSpace(_settings.SettingsFilePath) && - !string.Equals(_settings.SettingsFilePath, _settingsFilePath, StringComparison.OrdinalIgnoreCase)) - { - _settingsFilePath = _settings.SettingsFilePath; - } + RetargetLocked(_settings.SettingsFilePath); _logger.LogDebug("Settings updated in memory"); } @@ -113,12 +142,7 @@ public async Task TryUpdateAndSaveAsync(Func applyChan accepted = applyChanges(_settings); if (accepted) { - // propagate any internal path updates - if (!string.IsNullOrWhiteSpace(_settings.SettingsFilePath) && - !string.Equals(_settings.SettingsFilePath, _settingsFilePath, StringComparison.OrdinalIgnoreCase)) - { - _settingsFilePath = _settings.SettingsFilePath; - } + RetargetLocked(_settings.SettingsFilePath); } } @@ -144,16 +168,31 @@ public async Task TryUpdateAndSaveAsync(Func applyChan /// /// Cancellation token for the operation. /// A task that represents the asynchronous save operation. + /// + /// Thrown when the settings file the save would write has not been verified as safe to + /// overwrite, either because it could not be read or because the in-memory settings came from + /// a different file. + /// public async Task SaveAsync(CancellationToken cancellationToken = default) { UserSettings settingsToSave; - string pathToSave; + SettingsFileTarget target; lock (_lock) { - pathToSave = _settingsFilePath; + target = _target; settingsToSave = Get(); } + var pathToSave = target.Path; + if (!target.CanWrite) + { + _logger.LogError( + "Refusing to save settings to {Path}: the settings held in memory were not read from it, so saving would replace its contents with unrelated values", + pathToSave); + throw new InvalidOperationException( + $"The settings file '{pathToSave}' was never read into the current settings; saving would overwrite it with values that did not come from it."); + } + try { var directory = Path.GetDirectoryName(pathToSave); @@ -185,17 +224,35 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) } /// - /// Sets the settings file path for testing purposes. + /// Adopts as the settings file, reading it into the in-memory settings. + /// This is the "start using this file" move, and it necessarily discards the settings currently + /// held in memory, which is why the settings the user is editing are never re-pointed through it. /// /// The path to set. /// Thrown when is null, empty, or consists only of white-space characters. protected void SetSettingsFilePath(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path, nameof(path)); - _settingsFilePath = path; - _settings = LoadSettings(path); + + lock (_lock) + { + _settings = LoadSettings(path, out var outcome); + _target = TargetFor(path, outcome); + } } + /// + /// Pairs a settings file with what reading it produced, so a path can never be adopted without + /// the read that decides whether writing it is safe. + /// + /// The settings file that was read. + /// What reading it produced. + /// The target the service should hold. + private static SettingsFileTarget TargetFor(string path, SettingsLoadOutcome outcome) => + outcome == SettingsLoadOutcome.Failed + ? SettingsFileTarget.Unverified(path) + : SettingsFileTarget.Verified(path); + private static void NormalizeAndValidateLocked(UserSettings s, IAppConfiguration appConfig) { // Only apply basic validation/clamping, no defaults @@ -275,13 +332,27 @@ private static string ConvertJsonPropertyNameToCSharp(string jsonPropertyName) }; } - private UserSettings LoadSettings(string path) + /// + /// Reads the settings at , falling back to defaults on any failure. + /// + /// The settings file to read. + /// + /// Receives what the read produced. A missing or empty file is reported as + /// because it holds nothing a save could destroy; + /// anything else that stops the file from being turned into settings is reported as + /// . + /// + /// The settings that were read, or defaults when they could not be. + private UserSettings LoadSettings(string path, out SettingsLoadOutcome outcome) { + outcome = SettingsLoadOutcome.Failed; + try { if (!File.Exists(path)) { _logger.LogInformation("Settings file not found at {Path}, using defaults", path); + outcome = SettingsLoadOutcome.Absent; return new UserSettings(); } @@ -289,6 +360,7 @@ private UserSettings LoadSettings(string path) if (string.IsNullOrWhiteSpace(json)) { _logger.LogWarning("Settings file is empty at {Path}, using defaults", path); + outcome = SettingsLoadOutcome.Absent; return new UserSettings(); } @@ -303,6 +375,7 @@ private UserSettings LoadSettings(string path) MarkExplicitlySetPropertiesFromJson(settings, json); _logger.LogInformation("Settings loaded successfully from {Path}", path); + outcome = SettingsLoadOutcome.Loaded; return settings; } catch (IOException ex) @@ -322,6 +395,48 @@ private UserSettings LoadSettings(string path) } } + /// + /// Points saves at on behalf of a user who edited the settings file + /// location, reading it first so the move cannot leave the service treating an unread file as + /// safe to overwrite. + /// + /// + /// A path that already holds settings is adopted as the write target but left unverified, so + /// refuses instead of replacing that file with values derived from a + /// different one. Refusing rather than reloading is the only reading of the request that + /// destroys nothing: the file keeps its contents and the user keeps the edits they were saving, + /// and the ambiguity between "start using this file" and "save my settings there" is theirs to + /// resolve. Recovery needs no extra state, because pointing back at the verified file, or at + /// the same path once it no longer holds settings, verifies the target again. + /// + /// The requested settings file path. A blank path leaves the target alone. + private void RetargetLocked(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var moved = _target.MoveTo(path); + if (moved.CanWrite) + { + _target = moved; + return; + } + + LoadSettings(path, out var outcome); + if (outcome == SettingsLoadOutcome.Absent) + { + _target = SettingsFileTarget.Verified(path); + return; + } + + _logger.LogError( + "Refusing to adopt {Path} as the settings file: it already holds settings that the settings in memory were not read from, so saving there would replace them", + path); + _target = moved; + } + private string GetDefaultSettingsFilePath() { if (_appConfig == null) @@ -334,29 +449,159 @@ private string GetDefaultSettingsFilePath() return Path.Combine(_appConfig.GetConfiguredDataPath(), FileTypes.SettingsFileName); } + /// + /// Resolves the file the settings are read from. When the current data root holds no settings + /// file, the pre-upgrade roaming location is read instead so an upgrading user keeps their + /// settings on the first launch rather than starting from defaults and then overwriting the + /// migrated file on the first save. Writes always target ; moving + /// the file remains the responsibility of the legacy data root migration. + /// + /// The settings file path for the current data root. + /// The path the settings should be read from. + private string ResolveSettingsSourcePath(string defaultPath) + { + try + { + if (_appConfig == null || File.Exists(defaultPath)) + { + return defaultPath; + } + + var legacyRoot = _appConfig.GetLegacyConfiguredDataPath(); + var legacyPath = LegacySettingsFileNames + .Select(name => Path.Combine(legacyRoot, name)) + .FirstOrDefault(path => !PathHelper.AreSamePath(path, defaultPath) && File.Exists(path)); + + if (legacyPath is not null) + { + _logger.LogInformation( + "No settings file at {DefaultPath}, reading pre-upgrade settings from {LegacyPath}", + defaultPath, + legacyPath); + return legacyPath; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + { + _logger.LogWarning(ex, "Failed to look for pre-upgrade settings, falling back to {DefaultPath}", defaultPath); + } + + return defaultPath; + } + + /// + /// Loads the settings and resolves the path they are persisted to. + /// + /// + /// A failure here leaves the target unverified, which blocks + /// rather than letting the session persist defaults over a settings file that was never read. + /// That covers both the exceptions that escape to the outer catch and the ones + /// swallows, which is why the source it read has to report whether it + /// was absent, read, or unreadable: only an unreadable source has values a save could destroy, + /// and that holds for the pre-upgrade source just as much as for the current one. + /// Normalization is applied separately: clamping to an inconsistent configured range is no reason + /// to discard settings that loaded fine. + /// private void InitializeSettings() { - // 1. Load from default path to determine if a custom path is set. - var defaultPath = GetDefaultSettingsFilePath(); - var initialSettings = LoadSettings(defaultPath); + try + { + var defaultPath = GetDefaultSettingsFilePath(); + var initialSettings = LoadSettings(ResolveSettingsSourcePath(defaultPath), out var outcome); + + // If the user has a custom path, reload from there; otherwise keep what the default path gave us. + string writePath; + if (!string.IsNullOrWhiteSpace(initialSettings.SettingsFilePath) && + !PathHelper.AreSamePath(initialSettings.SettingsFilePath, defaultPath)) + { + writePath = initialSettings.SettingsFilePath; + _settings = LoadSettings(writePath, out outcome); + } + else + { + writePath = defaultPath; + _settings = initialSettings; + } + + _target = TargetFor(writePath, outcome); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize settings, continuing with defaults and without persistence"); + _settings = new UserSettings(); + _target = SettingsFileTarget.Unverified(string.Empty); + return; + } - // 2. If user has a custom path, reload from that path. Otherwise, use the settings from the default path. - if (!string.IsNullOrWhiteSpace(initialSettings.SettingsFilePath) && - !string.Equals(initialSettings.SettingsFilePath, defaultPath, StringComparison.OrdinalIgnoreCase)) + try { - _settingsFilePath = initialSettings.SettingsFilePath; - _settings = LoadSettings(_settingsFilePath); + lock (_lock) + { + NormalizeAndValidateLocked(_settings, _appConfig); + } } - else + catch (ArgumentException ex) { - _settingsFilePath = defaultPath; - _settings = initialSettings; + _logger.LogError(ex, "Failed to normalize settings, keeping the loaded values as they are"); } + } - // Apply validation and normalization - lock (_lock) + /// + /// The settings file a save writes to, paired with the file that was last verified as safe to + /// overwrite. + /// + /// + /// The pairing is what makes the guard hold structurally. The two facts live in one immutable + /// value with a private constructor, so a caller cannot move the write path and leave a stale + /// "already read" flag behind it: the only ways to produce a target are to state that a path was + /// verified, to state that it was not, or to move away from a verified path, which drops the + /// permission to write with it. + /// + private sealed class SettingsFileTarget + { + private SettingsFileTarget(string path, string verifiedPath) { - NormalizeAndValidateLocked(_settings, _appConfig); + Path = path; + VerifiedPath = verifiedPath; } + + /// + /// Gets the settings file a save writes to. + /// + public string Path { get; } + + /// + /// Gets the settings file last verified as safe to overwrite, either because it was read + /// into the in-memory settings or because it held nothing a save could destroy. Empty when + /// no file has been verified. + /// + public string VerifiedPath { get; } + + /// + /// Gets a value indicating whether saving writes the file the in-memory settings account + /// for rather than an unrelated one. + /// + public bool CanWrite => VerifiedPath.Length > 0 && PathHelper.AreSamePath(Path, VerifiedPath); + + /// + /// Creates a target for a file that was read, or that held nothing a save could destroy. + /// + /// The settings file. + /// A target that may be written. + public static SettingsFileTarget Verified(string path) => new(path, path); + + /// + /// Creates a target for a file holding settings the in-memory settings do not account for. + /// + /// The settings file. + /// A target that must not be written. + public static SettingsFileTarget Unverified(string path) => new(path, string.Empty); + + /// + /// Moves the write path, carrying the verified file rather than the permission to write. + /// + /// The settings file to write from now on. + /// The moved target, writable only when it lands back on the verified file. + public SettingsFileTarget MoveTo(string path) => new(path, VerifiedPath); } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs index b65437505..e72a12c44 100644 --- a/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs +++ b/GenHub/GenHub/Features/Manifest/ManifestDiscoveryService.cs @@ -127,7 +127,7 @@ public async Task InitializeCacheAsync(CancellationToken cancellationToken = def // is honoured; a raw SpecialFolder lookup would keep reading the default tree. var applicationDataPath = configurationProvider.GetApplicationDataPath(); var localManifestDir = Path.Combine(applicationDataPath, FileTypes.ManifestsDirectory); - var customManifestDir = Path.Combine(applicationDataPath, "CustomManifests"); + var customManifestDir = Path.Combine(applicationDataPath, DirectoryNames.CustomManifests); await DiscoverFileSystemManifestsAsync([localManifestDir, customManifestDir], cancellationToken); diff --git a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs index 1e6fc31a1..9f97f5367 100644 --- a/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs +++ b/GenHub/GenHub/Features/Settings/ViewModels/SettingsViewModel.cs @@ -597,6 +597,10 @@ private async Task SaveSettings() catch (Exception ex) { _logger.LogError(ex, "Failed to save settings"); + _notificationService.ShowError( + "Settings Not Saved", + ex.Message, + (int)TimeIntervals.NotificationHideDelay.TotalMilliseconds); } finally { diff --git a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs index e87f5354a..d78508e35 100644 --- a/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/UserDataTrackerService.cs @@ -35,10 +35,10 @@ public class UserDataTrackerService( private static readonly SemaphoreSlim IndexLock = new(1, 1); private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; - private readonly string _userDataTrackingPath = Path.Combine(configProvider.GetApplicationDataPath(), "UserData"); - private readonly string _manifestsPath = Path.Combine(configProvider.GetApplicationDataPath(), "UserData", "manifests"); - private readonly string _backupsPath = Path.Combine(configProvider.GetApplicationDataPath(), "UserData", "backups"); - private readonly string _indexPath = Path.Combine(configProvider.GetApplicationDataPath(), "UserData", "index.json"); + private readonly string _userDataTrackingPath = Path.Combine(configProvider.GetApplicationDataPath(), DirectoryNames.UserData); + private readonly string _manifestsPath = Path.Combine(configProvider.GetApplicationDataPath(), DirectoryNames.UserData, DirectoryNames.UserDataManifests); + private readonly string _backupsPath = Path.Combine(configProvider.GetApplicationDataPath(), DirectoryNames.UserData, DirectoryNames.UserDataBackups); + private readonly string _indexPath = Path.Combine(configProvider.GetApplicationDataPath(), DirectoryNames.UserData, FileTypes.UserDataIndexFileName); private UserDataIndex? _cachedIndex; diff --git a/GenHub/GenHub/Features/Workspace/WorkspaceManager.cs b/GenHub/GenHub/Features/Workspace/WorkspaceManager.cs index e4c46fa18..57e7483fa 100644 --- a/GenHub/GenHub/Features/Workspace/WorkspaceManager.cs +++ b/GenHub/GenHub/Features/Workspace/WorkspaceManager.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Storage; @@ -35,7 +36,7 @@ WorkspaceReconciler reconciler private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; // Stores workspace metadata in the application data directory - private readonly string _workspaceMetadataPath = Path.Combine(configurationProvider.GetApplicationDataPath(), "workspaces.json"); + private readonly string _workspaceMetadataPath = Path.Combine(configurationProvider.GetApplicationDataPath(), FileTypes.WorkspaceMetadataFileName); /// /// Prepares a workspace using the specified configuration and strategy. diff --git a/docs/dev/constants.md b/docs/dev/constants.md index a1d9ba2ce..d0c884cd8 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -211,14 +211,21 @@ Constants for unit conversions used throughout the application. Directory names used for organizing content storage. -| Constant | Value | Description | -| --------- | ------------ | ----------------------------- | -| `Data` | `"Data"` | Directory for content data | -| `Cache` | `"Cache"` | Directory for cache files | -| `CasPool` | `"cas-pool"` | Directory for CAS pool | -| `Temp` | `"Temp"` | Directory for temporary files | -| `Logs` | `"Logs"` | Directory for log files | -| `Backups` | `"Backups"` | Directory for backup files | +| Constant | Value | Description | +| ------------------- | -------------- | ------------------------------------------------------------------------ | +| `Data` | `"Data"` | Directory for content data | +| `Cache` | `"Cache"` | Directory for cache files | +| `CasPool` | `"cas-pool"` | Directory for CAS pool | +| `Temp` | `"Temp"` | Directory for temporary files | +| `Logs` | `"Logs"` | Directory for log files | +| `Backups` | `"Backups"` | Directory for backup files | +| `Profiles` | `"Profiles"` | Directory for game profiles | +| `UserData` | `"UserData"` | Directory for tracked user data | +| `UserDataManifests` | `"manifests"` | Manifests of tracked user data, nested in `UserData` (exact on-disk case) | +| `UserDataBackups` | `"backups"` | Backups of replaced user data files, nested in `UserData` (exact on-disk case) | +| `Workspaces` | `"Workspaces"` | Directory for workspaces | +| `ToolWorkspaces` | `"ToolWorkspaces"` | Directory for tool workspaces | +| `LegacyContent` | `"Content"` | Sub-layout used up to v0.0.3; probed only by the upgrade migration | --- @@ -252,11 +259,14 @@ File and directory name constants to prevent typos and ensure consistency. ### JSON Files -| Constant | Value | Description | -| ------------------- | ----------------- | ----------------------------- | -| `JsonFileExtension` | `".json"` | File extension for JSON files | -| `JsonFilePattern` | `"*.json"` | File pattern for JSON files | -| `SettingsFileName` | `"settings.json"` | Default settings file name | +| Constant | Value | Description | +| ---------------------------- | ------------------- | --------------------------------------------------------------------- | +| `JsonFileExtension` | `".json"` | File extension for JSON files | +| `JsonFilePattern` | `"*.json"` | File pattern for JSON files | +| `SettingsFileName` | `"settings.json"` | Default settings file name | +| `LegacySettingsFileName` | `".json"` | Settings file name written up to v0.0.3; probed only by the upgrade migration | +| `WorkspaceMetadataFileName` | `"workspaces.json"` | File holding the persisted workspace metadata | +| `UserDataIndexFileName` | `"index.json"` | Index of installed user data, nested in `UserData` | --- From 6cfa705a5db8d9f9b04e5b06a16e39327ff7c149 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:24:27 -0400 Subject: [PATCH 10/20] fix(tests): pass the dialog service to the settings view model constructions that development added (#402) --- .../GameProfiles/ViewModels/SettingsViewModelTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index d00fe9ed4..f0222fa22 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -211,7 +211,8 @@ public void Constructor_LoadsPeriodicUpdateSettingsFromUserSettingsService() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object); + _mockUserDataTracker.Object, + _mockDialogService.Object); // Assert Assert.False(viewModel.AutoCheckForUpdatesPeriodically); @@ -238,7 +239,8 @@ public async Task SaveSettingsCommand_UpdatesPeriodicUpdateSettingsAsync() _mockConfigurationProvider.Object, _mockInstallationService.Object, _mockStorageLocationService.Object, - _mockUserDataTracker.Object) + _mockUserDataTracker.Object, + _mockDialogService.Object) { AutoCheckForUpdatesPeriodically = false, PeriodicUpdateCheckIntervalMinutes = 45, From eba0d9a09b7a2feb3284a8a4d13c6c1644b5ca72 Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:32:05 -0400 Subject: [PATCH 11/20] fix(content): bound archive extraction and propagate cancellation from deliverers (#385) * fix(content): bound archive extraction and propagate cancellation from deliverers * fix(content): stage overwriting archive writes and reject entries once the expansion budget is spent * fix(content): propagate cancellation instead of reporting a truncated import as success * fix(maps): skip only the failing map and release the bytes counted for a discarded one * fix(content): refuse archive entry names that cannot name a file * fix(core): follow symbolic links when testing path containment * fix(core): bound the staging name, isolate its cleanup and name a spent budget * fix(maps): create the map directory inside the per-map failure handler * docs(constants): correct the binary units on the Community Outpost expansion caps * test(content): cancel extraction mid-entry and cover the deliverers' entry checks * test(core): fail loudly when the spoofed-size fixture's ZIP layout drifts --- GenHub/Directory.Packages.props | 2 +- .../Constants/CommunityOutpostConstants.cs | 16 + .../GenHub.Core/Constants/GitHubConstants.cs | 27 ++ GenHub/GenHub.Core/Constants/IoConstants.cs | 7 + .../ArchiveExpansionLimitExceededException.cs | 76 ++++ GenHub/GenHub.Core/Helpers/PathHelper.cs | 74 ++++ .../GenHub.Core/Utilities/ArchiveEntryName.cs | 79 ++++ .../Utilities/BoundedArchiveExtractor.cs | 128 +++++++ .../CommunityOutpostDelivererTests.cs | 273 ++++++++++++++ .../GitHub/GitHubContentDelivererTests.cs | 287 ++++++++++++++ .../Tools/Services/MapImportServiceTests.cs | 179 +++++++++ .../Services/ReplayImportServiceTests.cs | 120 ++++++ .../Helpers/PathHelperTests.cs | 135 +++++++ .../Infrastructure/ArchiveFixtures.cs | 69 ++++ .../Utilities/ArchiveEntryNameTests.cs | 75 ++++ .../Utilities/BoundedArchiveExtractorTests.cs | 352 ++++++++++++++++++ .../CommunityOutpostDeliverer.cs | 71 +++- .../Content/Services/ContentStorageService.cs | 14 +- .../Services/GitHub/GitHubContentDeliverer.cs | 82 ++-- .../MapManager/Services/MapImportService.cs | 111 ++++-- .../Services/ReplayImportService.cs | 37 +- docs/dev/constants.md | 1 + 22 files changed, 2127 insertions(+), 88 deletions(-) create mode 100644 GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs create mode 100644 GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs create mode 100644 GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index f76660ae2..b97095865 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -47,7 +47,7 @@ - + diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs index 305d9555d..d15f6516c 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs @@ -85,6 +85,22 @@ public static class CommunityOutpostConstants /// public const string PatchPageUrl = "https://legi.cc/downloads/genpatcher/"; + /// + /// Maximum number of file entries a downloaded Community Outpost archive may contain. + /// + public const int MaxArchiveEntries = 10000; + + /// + /// Maximum number of bytes a single Community Outpost archive entry may expand to (2 GiB), + /// sized to accommodate the largest shipped BIG files. + /// + public const long MaxEntryUncompressedBytes = 2L * 1024 * 1024 * 1024; + + /// + /// Maximum aggregate uncompressed bytes a Community Outpost archive may expand to (4 GiB). + /// + public const long MaxAggregateUncompressedBytes = 4L * 1024 * 1024 * 1024; + /// Display name for Game Clients content type. public const string ContentTypeGameClients = "Game Clients"; diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs index af738db30..0a567a174 100644 --- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs +++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs @@ -333,6 +333,33 @@ public static class GitHubConstants /// Description for GitHub content deliverer. public const string GitHubDelivererDescription = "Delivers GitHub content including release archives"; + // Archive extraction limits + // GitHub caps a single release asset at 2 GiB, so a downloaded archive can never exceed that + // compressed. These bounds leave generous headroom above real game content while keeping an + // archive that lies about its declared sizes from expanding without limit. + + /// Maximum number of file entries a downloaded GitHub archive may contain. + public const int MaxArchiveEntries = 50000; + + /// Maximum number of bytes a single GitHub archive entry may expand to (4 GiB). + public const long MaxEntryUncompressedBytes = 4L * 1024 * 1024 * 1024; + + /// Maximum aggregate uncompressed bytes a GitHub archive may expand to (16 GiB). + public const long MaxAggregateUncompressedBytes = 16L * 1024 * 1024 * 1024; + + /// + /// Maximum factor by which a GitHub archive may expand beyond its own downloaded size. Release + /// archives are deflate-compressed game content and executables, which run well under 20:1, so + /// this bounds a small archive that claims to hold very little and then inflates without end. + /// + public const long MaxArchiveExpansionRatio = 500; + + /// + /// Floor for the ratio-derived expansion budget (8 MiB), so a very small archive still gets + /// room for content that compresses unusually well and is judged only by the absolute caps. + /// + public const long MinArchiveExpansionBudgetBytes = 8L * 1024 * 1024; + // Metadata keys /// Metadata key for repository owner. diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs index c49e2e66d..5b99c5710 100644 --- a/GenHub/GenHub.Core/Constants/IoConstants.cs +++ b/GenHub/GenHub.Core/Constants/IoConstants.cs @@ -15,4 +15,11 @@ public static class IoConstants /// themselves reached through links. Bounds the walk on a filesystem that contains a cycle. /// public const int MaxSymbolicLinkResolutionDepth = 8; + + /// + /// Suffix that marks a staging file written beside its final location so the existing file is + /// only replaced once the write has completed. The name it is appended to is random rather than + /// the destination name, which keeps a staged write from outgrowing the Windows path limit. + /// + public const string StagingFileSuffix = ".genhub-staging"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs new file mode 100644 index 000000000..448a6bc00 --- /dev/null +++ b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs @@ -0,0 +1,76 @@ +using System; + +namespace GenHub.Core.Exceptions; + +/// +/// Exception thrown when an archive entry expands past the budget allowed for it, which means the +/// size declared in the archive headers understated the real decompressed size. +/// +public class ArchiveExpansionLimitExceededException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ArchiveExpansionLimitExceededException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public ArchiveExpansionLimitExceededException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public ArchiveExpansionLimitExceededException(string message, Exception? inner) + : base(message, inner) + { + } + + /// + /// Initializes a new instance of the class + /// for a named entry that exceeded a byte budget. + /// + /// The archive-relative name of the offending entry. + /// The number of bytes the entry was allowed to expand to. + public ArchiveExpansionLimitExceededException(string entryName, long limitBytes) + : this($"Archive entry '{entryName}' expanded past the allowed {limitBytes} bytes (potential zip bomb).", entryName, limitBytes) + { + } + + private ArchiveExpansionLimitExceededException(string message, string entryName, long limitBytes) + : base(message) + { + EntryName = entryName; + LimitBytes = limitBytes; + } + + /// + /// Gets the archive-relative name of the offending entry. + /// + public string EntryName { get; } = string.Empty; + + /// + /// Gets the number of bytes the entry was allowed to expand to. + /// + public long LimitBytes { get; } + + /// + /// Creates an exception for an entry refused because the archive-wide expansion budget was + /// already spent, so no byte of it was ever read. + /// + /// The archive-relative name of the refused entry. + /// An exception describing the spent budget. + public static ArchiveExpansionLimitExceededException ForSpentBudget(string entryName) => + new( + $"Archive entry '{entryName}' was refused because the archive-wide expansion budget was already spent (potential zip bomb).", + entryName, + 0); +} diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 2514312f7..288f86fe2 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -62,4 +62,78 @@ public static string GetSafeParentDirectory(string path) var parent = Path.GetDirectoryName(path); return string.IsNullOrEmpty(parent) ? path : parent; } + + /// + /// Determines whether a candidate path resolves to a location inside a base directory. + /// Both paths are fully normalized first, so .. segments, redundant separators and + /// rooted candidates cannot escape the base directory. Because normalization is textual and a + /// symbolic link or junction redirects a path that reads as contained, both sides are also + /// compared after their links are followed; a path that cannot be resolved — because it does + /// not exist yet, or the filesystem refuses the query — is compared as written. + /// + /// The directory that must contain the candidate path. + /// The path to test for containment. + /// when the candidate resolves inside the base directory; otherwise, . + public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath) + { + var normalizedRoot = Path.GetFullPath(baseDirectory); + var normalizedTarget = Path.GetFullPath(candidatePath); + + return IsContained(normalizedRoot, normalizedTarget) && + IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + } + + private static bool IsContained(string normalizedRoot, string normalizedTarget) + { + var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); + + return !relative.Equals("..", StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && + !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) && + !Path.IsPathRooted(relative); + } + + private static string FollowLinks(string fullPath) + { + try + { + var existing = fullPath; + var remainder = string.Empty; + + while (!Directory.Exists(existing) && !File.Exists(existing)) + { + var parent = Path.GetDirectoryName(existing); + if (string.IsNullOrEmpty(parent)) + { + return fullPath; + } + + remainder = Path.Combine(Path.GetFileName(existing), remainder); + existing = parent; + } + + FileSystemInfo info = Directory.Exists(existing) + ? new DirectoryInfo(existing) + : new FileInfo(existing); + var resolved = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? existing; + + return remainder.Length == 0 ? resolved : Path.GetFullPath(Path.Combine(resolved, remainder)); + } + catch (IOException) + { + return fullPath; + } + catch (UnauthorizedAccessException) + { + return fullPath; + } + catch (NotSupportedException) + { + return fullPath; + } + catch (ArgumentException) + { + return fullPath; + } + } } diff --git a/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs b/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs new file mode 100644 index 000000000..967b3c0f4 --- /dev/null +++ b/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs @@ -0,0 +1,79 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +namespace GenHub.Core.Utilities; + +/// +/// Screens archive entry names before they are turned into filesystem paths. Names come from +/// third-party archives, so a name the host cannot represent has to be refused up front rather +/// than left to fail somewhere inside the write: an empty name in particular collapses +/// onto the extraction directory itself, which puts the +/// write on the directory instead of on a file inside it. +/// +public static class ArchiveEntryName +{ + private static readonly char[] SeparatorChars = ['/', '\\']; + + private static readonly char[] UnusableChars = + ['\"', '<', '>', '|', ':', '*', '?', .. Enumerable.Range(0, 32).Select(value => (char)value)]; + + private static readonly string[] ReservedDeviceNames = + [ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + + /// + /// Determines whether an archive entry name can be combined with an extraction directory to + /// name a file. A name that resolves to the directory itself is refused, as is one the + /// strictest supported host cannot represent, so an archive behaves the same everywhere: that + /// rules out reserved device names and the characters Windows forbids, including the colon that + /// would otherwise open an NTFS alternate data stream. Traversal in the middle of a name is not + /// judged here; that stays with the containment check that follows. + /// + /// The archive-relative entry name to screen. + /// when the name can be extracted; otherwise, . + public static bool IsExtractable([NotNullWhen(true)] string? entryName) + { + if (string.IsNullOrWhiteSpace(entryName)) + { + return false; + } + + if (entryName.EndsWith('/') || entryName.EndsWith('\\')) + { + return false; + } + + var segments = entryName.Split(SeparatorChars, StringSplitOptions.RemoveEmptyEntries); + + return segments.Length > 0 && + segments[^1] is not ("." or "..") && + segments.All(IsExtractableSegment); + } + + private static bool IsExtractableSegment(string segment) + { + if (string.IsNullOrWhiteSpace(segment)) + { + return false; + } + + if (segment is not ("." or "..") && (segment.EndsWith('.') || segment.EndsWith(' '))) + { + return false; + } + + if (segment.IndexOfAny(UnusableChars) >= 0) + { + return false; + } + + var deviceName = segment.Split('.')[0]; + + return !ReservedDeviceNames.Contains(deviceName, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs b/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs new file mode 100644 index 000000000..b1f9095e0 --- /dev/null +++ b/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Exceptions; + +namespace GenHub.Core.Utilities; + +/// +/// Streams archive entries to disk under an expansion budget. Sizes recorded in archive headers are +/// attacker-controlled, so the budget is measured against the bytes actually decompressed and the +/// copy aborts the moment it is exceeded. +/// +public static class BoundedArchiveExtractor +{ + /// + /// Copies a decompressed archive entry to , aborting as soon as the + /// per-entry cap or the remaining archive-wide budget is exhausted. When + /// is set the entry is staged beside its destination and moved into place only once the copy has + /// completed, so a failure leaves any pre-existing file intact and removes only what this call wrote. + /// + /// The decompressed entry stream to read from. + /// The file to write the entry to. + /// The archive-relative entry name, used in failure messages. + /// Maximum number of bytes a single entry may expand to. + /// Bytes still available in the archive-wide budget. + /// Whether an existing destination file may be replaced. + /// Token used to cancel the copy. + /// The number of bytes written. + /// Thrown when the budget is already exhausted or the entry expands past it. + public static async Task CopyEntryToFileAsync( + Stream entryStream, + string destinationPath, + string entryName, + long maxEntryBytes, + long remainingAggregateBytes, + bool overwrite = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entryStream); + + var limit = Math.Min(maxEntryBytes, remainingAggregateBytes); + if (limit <= 0) + { + throw ArchiveExpansionLimitExceededException.ForSpentBudget(entryName); + } + + var buffer = new byte[IoConstants.DefaultFileBufferSize]; + long written = 0; + + var writePath = overwrite ? BuildStagingPath(destinationPath) : destinationPath; + var destination = new FileStream(writePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + + try + { + int read = 0; + while ((read = await entryStream.ReadAsync(buffer, cancellationToken)) > 0) + { + written += read; + if (written > limit) + { + throw new ArchiveExpansionLimitExceededException(entryName, limit); + } + + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + } + + await destination.DisposeAsync(); + + if (overwrite) + { + File.Move(writePath, destinationPath, overwrite: true); + } + } + catch + { + await DisposeQuietlyAsync(destination); + DeletePartialOutput(writePath); + throw; + } + + return written; + } + + private static string BuildStagingPath(string destinationPath) + { + var directory = Path.GetDirectoryName(destinationPath); + var stagingName = Path.GetRandomFileName() + IoConstants.StagingFileSuffix; + + return string.IsNullOrEmpty(directory) ? stagingName : Path.Combine(directory, stagingName); + } + + private static async Task DisposeQuietlyAsync(FileStream destination) + { + try + { + await destination.DisposeAsync(); + } + catch (IOException) + { + // The failure being handled is the one worth surfacing, not a flush that fails after it. + } + catch (UnauthorizedAccessException) + { + // The failure being handled is the one worth surfacing, not a flush that fails after it. + } + } + + private static void DeletePartialOutput(string writePath) + { + try + { + if (File.Exists(writePath)) + { + File.Delete(writePath); + } + } + catch (IOException) + { + // Best effort cleanup; the original failure is the one worth surfacing. + } + catch (UnauthorizedAccessException) + { + // Best effort cleanup; the original failure is the one worth surfacing. + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs new file mode 100644 index 000000000..90ac6ba7d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,273 @@ +using System.IO.Compression; +using System.Reflection; +using System.Text; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Content.Services.CommunityOutpost; + +/// +/// Tests the containment and expansion bounds applied to Community Outpost archives, which arrive +/// from a third-party catalog and are therefore untrusted input. +/// +public sealed class CommunityOutpostDelivererTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubCommunityOutpost", + Guid.NewGuid().ToString("N")); + + private readonly string _extractDirectory; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostDelivererTests() + { + _extractDirectory = Path.Combine(_workingDirectory, "extracted"); + Directory.CreateDirectory(_extractDirectory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Extracts entries that stay inside the target directory. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_ExtractsEntriesWithinBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "content.zip"); + CreateArchive(archivePath, "patch/readme.txt", "generals.big"); + + await InvokeExtractArchiveAsync(archivePath, _extractDirectory); + + Assert.True(File.Exists(Path.Combine(_extractDirectory, "patch", "readme.txt"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "generals.big"))); + } + + /// + /// Refuses an entry whose key climbs out of the extract directory, rather than depending on the + /// archive library to block it. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsEntryEscapingTheExtractDirectoryAsync() + { + var archivePath = Path.Combine(_workingDirectory, "traversal.zip"); + CreateArchive(archivePath, "../escaped.big"); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("outside target directory", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(Path.Combine(_workingDirectory, "escaped.big"))); + } + + /// + /// Refuses an archive that declares more entries than the extraction budget allows. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsArchiveOverTheEntryBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "swarm.zip"); + var entryNames = Enumerable + .Range(0, CommunityOutpostConstants.MaxArchiveEntries + 1) + .Select(index => $"entry{index}.dat") + .ToArray(); + CreateArchive(archivePath, entryNames); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("too many entries", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_extractDirectory)); + } + + /// + /// Refuses an entry whose name cannot name a file before that name is turned into a path. A + /// name that resolves to the extract directory itself would otherwise stage the write beside + /// that directory rather than inside it, and a colon names an NTFS alternate data stream. + /// + /// The entry name the archive declares. + /// A task representing the asynchronous test. + [Theory] + [InlineData(".")] + [InlineData("patch/..")] + [InlineData(" ")] + [InlineData("payload.big:stream")] + public async Task ExtractArchiveAsync_RejectsEntryWithAnUnusableNameAsync(string entryName) + { + var archivePath = Path.Combine(_workingDirectory, "unusable.zip"); + CreateArchive(archivePath, entryName); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("cannot be extracted to a file", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_workingDirectory, "*.genhub-staging*")); + } + + /// + /// Surfaces a cancellation that lands part-way through extraction as a cancellation rather than + /// as an ordinary extraction failure, so callers can tell a user who changed their mind from a + /// hostile or broken archive. The cancellation is triggered once an early entry has landed on + /// disk and while a much larger one is still being written, which is what puts it inside the + /// entry loop rather than in front of it. The downloaded archive is the only complete copy of + /// the content, so it must survive, and the truncated file set must never reach the manifest + /// pool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeliverContentAsync_CancelledMidExtraction_KeepsArchiveAndRegistersNothingAsync() + { + const int largeEntryBytes = 32 * 1024 * 1024; + var targetDirectory = Path.Combine(_workingDirectory, "target"); + Directory.CreateDirectory(targetDirectory); + + var downloadService = new Mock(); + downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + CreateArchive(destination, ("first.dat", 16), ("marker.dat", 16), ("large.dat", largeEntryBytes)); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var manifestPool = new Mock(); + var deliverer = CreateDeliverer(downloadService.Object, manifestPool.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "content.zip", + DownloadUrl = "https://legi.cc/gp2/f/cbpr.zip", + }, + ], + }; + + var extractDirectory = Path.Combine(targetDirectory, "extracted"); + using var cancellation = new CancellationTokenSource(); + var cancelWhenMarkerLands = CancelWhenFileAppearsAsync( + Path.Combine(extractDirectory, "marker.dat"), + cancellation); + + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync(manifest, targetDirectory, null, cancellation.Token)); + + await cancelWhenMarkerLands; + + Assert.True( + File.Exists(Path.Combine(targetDirectory, "content.zip")), + "the archive is the only recoverable copy of the content"); + Assert.True( + File.Exists(Path.Combine(extractDirectory, "first.dat")), + "the cancellation has to land after extraction started, not in front of it"); + Assert.False( + File.Exists(Path.Combine(extractDirectory, "large.dat")), + "the entry being written when the cancellation landed must not be left behind"); + Assert.Empty(Directory.GetFileSystemEntries(extractDirectory, "*.genhub-staging*")); + + manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + private static CommunityOutpostDeliverer CreateDeliverer( + IDownloadService downloadService, + IContentManifestPool manifestPool) + { + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var manifestFactory = new CommunityOutpostManifestFactory( + NullLogger.Instance, + new Mock().Object, + converter); + + return new CommunityOutpostDeliverer( + downloadService, + manifestPool, + manifestFactory, + new Mock().Object, + new Mock().Object, + converter, + NullLogger.Instance); + } + + private static void CreateArchive(string archivePath, params string[] entryNames) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(entryName)); + } + } + + private static void CreateArchive(string archivePath, params (string EntryName, int ByteCount)[] entries) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var (entryName, byteCount) in entries) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(new byte[byteCount]); + } + } + + private static Task CancelWhenFileAppearsAsync(string path, CancellationTokenSource cancellation) + { + return Task.Factory.StartNew( + () => + { + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!File.Exists(path) && DateTime.UtcNow < deadline) + { + } + + cancellation.Cancel(); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + private static async Task InvokeExtractArchiveAsync(string archivePath, string extractPath) + { + var extract = typeof(CommunityOutpostDeliverer).GetMethod( + "ExtractArchiveAsync", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("CommunityOutpostDeliverer.ExtractArchiveAsync was not found."); + + await (Task)extract.Invoke(null, [archivePath, extractPath, CancellationToken.None])!; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs index 118fbb21a..cb4f1bbff 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs @@ -1,14 +1,21 @@ using FluentAssertions; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; using GenHub.Features.Content.Services.GitHub; using GenHub.Features.Content.Services.Publishers; +using GenHub.Tests.Core.Infrastructure; using Microsoft.Extensions.Logging; using Moq; using Xunit; +using System.IO.Compression; using System.Reflection; +using System.Text; namespace GenHub.Tests.Features.Content.Services.GitHub; @@ -83,4 +90,284 @@ public Task DeliverContentAsync_ShouldExtractZip_ForMatchingContentTypesAsync(Ge return Task.CompletedTask; } + + /// + /// Surfaces a cancellation that lands part-way through extraction as a cancellation. The + /// downloaded archive is the only complete copy of the content, so it must survive, and the + /// truncated file set must never reach the manifest pool. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_CancelledDuringExtraction_KeepsArchiveAndRegistersNothingAsync() + { + var targetDirectory = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + + try + { + const int entryCount = 6; + _downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + CreateArchive(destination, entryCount); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var deliverer = new GitHubContentDeliverer( + _downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "release.zip", + DownloadUrl = "https://github.com/user/repo/release.zip", + }, + ], + }; + + using var cancellation = new CancellationTokenSource(); + var progress = new CancelOnFirstReport(cancellation); + + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync(manifest, targetDirectory, progress, cancellation.Token)); + + var archivePath = Path.Combine(targetDirectory, "release.zip"); + File.Exists(archivePath).Should().BeTrue("the archive is the only recoverable copy of the content"); + + var extracted = Directory.GetFiles(targetDirectory, "entry*.dat", SearchOption.AllDirectories); + extracted.Length.Should().BeLessThan(entryCount); + + _manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Fails delivery when an archive understates the size it decompresses to. The lie is only + /// visible while inflating, so the copy has to abort mid-stream, drop the partial file, and + /// leave the truncated file set out of the manifest pool. The failure is a result, not a + /// cancellation, so callers can tell a hostile archive from a user who changed their mind. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_ArchiveUnderstatingItsDeclaredSize_FailsWithoutRegisteringAManifestAsync() + { + var targetDirectory = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + + try + { + _downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + ArchiveFixtures.CreateWithSpoofedEntrySize(destination, "payload.dat", 12 * 1024 * 1024, 4096); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var deliverer = new GitHubContentDeliverer( + _downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "release.zip", + DownloadUrl = "https://github.com/user/repo/release.zip", + }, + ], + }; + + var result = await deliverer.DeliverContentAsync(manifest, targetDirectory, cancellationToken: CancellationToken.None); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Contain("potential zip bomb"); + + File.Exists(Path.Combine(targetDirectory, "payload.dat")).Should().BeFalse("the partial output is removed"); + + _manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Refuses an entry whose key climbs out of the target directory rather than trusting the + /// archive library to block it. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchiveAsync_RejectsEntryEscapingTheTargetDirectoryAsync() + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "traversal.zip"); + CreateArchive(archivePath, "../escaped.dat"); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("outside target directory"); + File.Exists(Path.Combine(root, "escaped.dat")).Should().BeFalse(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Refuses an entry whose name cannot name a file before that name is turned into a path, + /// rather than letting the write fail several layers deeper with an unrelated error. + /// + /// The entry name the archive declares. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(".")] + [InlineData("assets/..")] + [InlineData(" ")] + [InlineData("payload.dat:stream")] + public async Task ExtractArchiveAsync_RejectsEntryWithAnUnusableNameAsync(string entryName) + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "unusable.zip"); + CreateArchive(archivePath, entryName); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("cannot be extracted to a file"); + Directory.GetFileSystemEntries(root, "*.genhub-staging*").Should().BeEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Refuses an archive that declares more entries than the extraction budget allows, before any + /// of them is written. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchiveAsync_RejectsArchiveOverTheEntryBudgetAsync() + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "swarm.zip"); + CreateArchive(archivePath, GitHubConstants.MaxArchiveEntries + 1); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("too many entries"); + Directory.GetFileSystemEntries(targetDirectory).Should().BeEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateWorkingDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + return root; + } + + private static async Task InvokeExtractArchiveAsync( + GitHubContentDeliverer deliverer, + string archivePath, + string targetDirectory) + { + var extract = typeof(GitHubContentDeliverer).GetMethod( + "ExtractArchiveAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("GitHubContentDeliverer.ExtractArchiveAsync was not found."); + + await (Task)extract.Invoke(deliverer, [archivePath, targetDirectory, null, CancellationToken.None])!; + } + + private static void CreateArchive(string archivePath, params string[] entryNames) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes("payload")); + } + } + + private static void CreateArchive(string archivePath, int entryCount) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + for (var index = 0; index < entryCount; index++) + { + var entry = archive.CreateEntry($"entry{index}.dat", CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes($"payload {index}")); + } + } + + private GitHubContentDeliverer CreateDeliverer() => + new(_downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + + private sealed class CancelOnFirstReport(CancellationTokenSource cancellation) : IProgress + { + public void Report(ContentAcquisitionProgress value) + { + if (value.Phase == ContentAcquisitionPhase.Extracting) + { + cancellation.Cancel(); + } + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs new file mode 100644 index 000000000..0fbf776e4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/MapImportServiceTests.cs @@ -0,0 +1,179 @@ +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using GenHub.Core.Interfaces.Tools.MapManager; +using GenHub.Core.Models.Enums; +using GenHub.Features.Tools.MapManager.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests how map ZIP archives are split into path segments, which drives both the traversal +/// check and the grouping of a map with its assets. +/// +public sealed class MapImportServiceTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubMapImport", + Guid.NewGuid().ToString("N")); + + private readonly string _mapDirectory; + private readonly MapImportService _service; + + /// + /// Initializes a new instance of the class. + /// + public MapImportServiceTests() + { + _mapDirectory = Path.Combine(_workingDirectory, "Maps"); + Directory.CreateDirectory(_mapDirectory); + + var directoryService = new Mock(); + directoryService.Setup(d => d.GetMapDirectory(It.IsAny())).Returns(_mapDirectory); + + _service = new MapImportService( + directoryService.Object, + new HttpClient(), + new MapNameParser(NullLogger.Instance), + NullLogger.Instance); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Rejects a backslash-separated traversal segment. Splitting on backslashes is what makes the + /// leading .. visible as its own segment. + /// + [Fact] + public void ValidateZip_RejectsBackslashTraversalSegment() + { + var zipPath = Path.Combine(_workingDirectory, "traversal.zip"); + CreateZip(zipPath, ("..\\escaped.map", "map")); + + var (isValid, errorMessage) = _service.ValidateZip(zipPath); + + Assert.False(isValid); + Assert.Contains("path traversal", errorMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves a map and its asset to the same backslash-separated directory. Without splitting on + /// backslashes each entry becomes its own directory, and the asset is reported as a directory + /// holding no map. + /// + [Fact] + public void ValidateZip_ResolvesBackslashSeparatedEntriesToTheSameDirectory() + { + var zipPath = Path.Combine(_workingDirectory, "backslash.zip"); + CreateZip( + zipPath, + ("Desert\\desert.map", "map"), + ("Desert\\map.tga", "thumbnail")); + + var (isValid, errorMessage) = _service.ValidateZip(zipPath); + + Assert.True(isValid, errorMessage); + } + + /// + /// Keeps an apostrophe inside a directory name intact, so the map and its assets stay grouped + /// under the directory the archive actually declared. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_KeepsDirectoryNamesContainingApostrophesIntactAsync() + { + var zipPath = Path.Combine(_workingDirectory, "apostrophe.zip"); + CreateZip( + zipPath, + ("Bob's Map/bob.map", "map"), + ("Bob's Map/map.tga", "thumbnail")); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + var imported = Assert.Single(result.ImportedMaps); + Assert.Equal("Bob's Map", imported.DirectoryName); + Assert.True(File.Exists(Path.Combine(_mapDirectory, "Bob's Map", "bob.map"))); + Assert.True(File.Exists(Path.Combine(_mapDirectory, "Bob's Map", "map.tga"))); + } + + /// + /// Surfaces a cancellation that lands part-way through an archive as a cancellation. Maps + /// extracted before the cancellation must not be reported as a successful import, because the + /// caller would otherwise treat a truncated map set as the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_CancelledMidArchive_DoesNotReportSuccessAsync() + { + var zipPath = Path.Combine(_workingDirectory, "cancelled.zip"); + CreateZip( + zipPath, + ("First/first.map", "map"), + ("Second/second.map", "map")); + + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync(() => + _service.ImportFromZipAsync( + zipPath, + GameType.ZeroHour, + new CancelOnFirstReport(cancellation), + cancellation.Token)); + + Assert.Single(Directory.GetDirectories(_mapDirectory)); + } + + /// + /// Skips only the map whose directory cannot be created and keeps importing the rest. Creating + /// that directory is the first thing done for a map and can fail on its own — here a file + /// already occupies the name — so it belongs inside the per-map handler rather than in front of + /// it, where one bad name would sink the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_MapDirectoryThatCannotBeCreated_SkipsOnlyThatMapAsync() + { + var zipPath = Path.Combine(_workingDirectory, "blocked.zip"); + CreateZip( + zipPath, + ("Blocked/blocked.map", "map"), + ("Second/second.map", "map")); + await File.WriteAllTextAsync(Path.Combine(_mapDirectory, "Blocked"), "not a directory"); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + var imported = Assert.Single(result.ImportedMaps); + Assert.Equal("Second", imported.DirectoryName); + Assert.NotEmpty(result.Errors); + Assert.False(Directory.Exists(Path.Combine(_mapDirectory, "Blocked"))); + } + + private static void CreateZip(string zipPath, params (string EntryName, string Content)[] entries) + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var (entryName, content) in entries) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(content)); + } + } + + private sealed class CancelOnFirstReport(CancellationTokenSource cancellation) : IProgress + { + public void Report(double value) => cancellation.Cancel(); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs new file mode 100644 index 000000000..0e296ef2b --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/ReplayImportServiceTests.cs @@ -0,0 +1,120 @@ +using System.IO.Compression; +using System.Text; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Tools.ReplayManager; +using GenHub.Core.Models.Enums; +using GenHub.Features.Tools.ReplayManager.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests how a replay archive import behaves when it is interrupted, which decides whether the +/// caller is told the archive was imported in full. +/// +public sealed class ReplayImportServiceTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubReplayImport", + Guid.NewGuid().ToString("N")); + + private readonly string _replayDirectory; + private readonly ReplayImportService _service; + + /// + /// Initializes a new instance of the class. + /// + public ReplayImportServiceTests() + { + _replayDirectory = Path.Combine(_workingDirectory, "Replays"); + Directory.CreateDirectory(_replayDirectory); + + var directoryService = new Mock(); + directoryService.Setup(d => d.GetReplayDirectory(It.IsAny())).Returns(_replayDirectory); + + var zipValidationService = new Mock(); + zipValidationService.Setup(z => z.ValidateZip(It.IsAny())).Returns((true, null)); + + _service = new ReplayImportService( + new Mock().Object, + directoryService.Object, + new Mock().Object, + zipValidationService.Object, + NullLogger.Instance); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Imports every entry of an archive that is never interrupted. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_ImportsEveryEntryAsync() + { + var zipPath = Path.Combine(_workingDirectory, "replays.zip"); + CreateZip(zipPath, "first.rep", "second.rep"); + + var result = await _service.ImportFromZipAsync(zipPath, GameType.ZeroHour); + + Assert.True(result.Success, string.Join(" ", result.Errors)); + Assert.Equal(2, result.FilesImported); + } + + /// + /// Surfaces a cancellation that lands part-way through an archive as a cancellation. Entries + /// imported before the cancellation must not be reported as a successful import, because the + /// caller would otherwise treat a truncated set of replays as the whole archive. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportFromZipAsync_CancelledMidArchive_DoesNotReportSuccessAsync() + { + var zipPath = Path.Combine(_workingDirectory, "cancelled.zip"); + CreateZip(zipPath, "first.rep", "second.rep"); + + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync(() => + _service.ImportFromZipAsync( + zipPath, + GameType.ZeroHour, + new CancelOnceAnEntryIsImported(cancellation), + cancellation.Token)); + + Assert.Single(Directory.GetFiles(_replayDirectory)); + } + + private static void CreateZip(string zipPath, params string[] entryNames) + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(entryName)); + } + } + + private sealed class CancelOnceAnEntryIsImported(CancellationTokenSource cancellation) : IProgress + { + private int _reports; + + public void Report(double value) + { + if (++_reports > 1) + { + cancellation.Cancel(); + } + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 59fe70edc..41e389ba1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -34,4 +34,139 @@ public void PathComparer_UsesWindowsOnlyCaseFolding() Assert.Equal(OperatingSystem.IsWindows(), pathsAreEqual); } + + /// + /// Accepts the base directory itself and anything nested beneath it. + /// + /// A candidate path relative to the base directory. + [Theory] + [InlineData("")] + [InlineData("file.dat")] + [InlineData("nested/deeper/file.dat")] + [InlineData("nested/../file.dat")] + public void IsPathWithinDirectory_AcceptsContainedPaths(string relativeCandidate) + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(baseDirectory, relativeCandidate); + + Assert.True(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects traversal segments, escapes that only appear after normalization, and sibling + /// directories that merely share a name prefix with the base directory. + /// + /// A candidate path relative to the base directory. + [Theory] + [InlineData("..")] + [InlineData("../escaped.dat")] + [InlineData("nested/../../escaped.dat")] + [InlineData("../GenHubContainmentEvil/escaped.dat")] + public void IsPathWithinDirectory_RejectsEscapingPaths(string relativeCandidate) + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(baseDirectory, relativeCandidate); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects a rooted candidate that resolves outside the base directory. + /// + [Fact] + public void IsPathWithinDirectory_RejectsAbsolutePathOutsideBase() + { + var baseDirectory = Path.Combine(Path.GetTempPath(), "GenHubContainment"); + var candidate = Path.Combine(Path.GetTempPath(), "GenHubElsewhere", "escaped.dat"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + + /// + /// Rejects a candidate that reads as contained but leaves the base directory through a symbolic + /// link, which textual normalization alone cannot see. GenHub builds symlinked workspaces, so a + /// link inside a directory being written to is an ordinary shape rather than a contrived one. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "escaped.dat"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Accepts a candidate beneath a symbolic link that stays inside the base directory, so + /// following links tightens the check without refusing content a link merely reorganizes. + /// + [Fact] + public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysInside() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var inside = Path.Combine(baseDirectory, "real"); + Directory.CreateDirectory(inside); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), inside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "contained.dat"); + + Assert.True(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateWorkingDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "GenHubContainmentLinks", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + return root; + } + + private static bool TryCreateDirectorySymbolicLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs new file mode 100644 index 000000000..2bd636ef4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ArchiveFixtures.cs @@ -0,0 +1,69 @@ +using System.IO.Compression; + +namespace GenHub.Tests.Core.Infrastructure; + +/// +/// Builds archive fixtures for extraction tests. +/// +internal static class ArchiveFixtures +{ + private const int EndOfCentralDirectoryLength = 22; + private const int CentralDirectoryOffsetField = 16; + private const int CentralUncompressedSizeField = 24; + private const int CentralLocalHeaderOffsetField = 42; + private const int LocalUncompressedSizeField = 22; + private const int EndOfCentralDirectorySignature = 0x06054b50; + private const int CentralDirectorySignature = 0x02014b50; + private const int LocalFileHeaderSignature = 0x04034b50; + + /// + /// Writes a single-entry archive that advertises a harmless size and then inflates to a much + /// larger one, which is the shape of a hostile archive that only gives itself away part-way + /// through decompression. + /// + /// The archive to write. + /// The name of the single entry. + /// The number of bytes the entry really decompresses to. + /// The size the archive headers advertise. + public static void CreateWithSpoofedEntrySize( + string archivePath, + string entryName, + int actualBytes, + int declaredBytes) + { + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + entryStream.Write(new byte[actualBytes]); + } + + // Rewrite the uncompressed-size fields in both the central directory record and the local + // file header. Offsets follow the ZIP layout: the end-of-central-directory record ends the + // file and points at the central directory, whose record points back at the local header. + var bytes = File.ReadAllBytes(archivePath); + var endOfCentralDirectory = bytes.Length - EndOfCentralDirectoryLength; + RequireSignature(bytes, endOfCentralDirectory, EndOfCentralDirectorySignature, "end-of-central-directory record"); + + var centralDirectory = BitConverter.ToInt32(bytes, endOfCentralDirectory + CentralDirectoryOffsetField); + RequireSignature(bytes, centralDirectory, CentralDirectorySignature, "central directory record"); + + var localHeader = BitConverter.ToInt32(bytes, centralDirectory + CentralLocalHeaderOffsetField); + RequireSignature(bytes, localHeader, LocalFileHeaderSignature, "local file header"); + + BitConverter.GetBytes(declaredBytes).CopyTo(bytes, centralDirectory + CentralUncompressedSizeField); + BitConverter.GetBytes(declaredBytes).CopyTo(bytes, localHeader + LocalUncompressedSizeField); + File.WriteAllBytes(archivePath, bytes); + } + + private static void RequireSignature(byte[] bytes, int offset, int signature, string recordName) + { + if (offset < 0 || offset + sizeof(int) > bytes.Length || + BitConverter.ToInt32(bytes, offset) != signature) + { + throw new InvalidOperationException( + $"Expected a ZIP {recordName} at offset {offset}. The layout written by ZipFile has drifted, " + + "so patching these offsets would corrupt the fixture instead of resizing its entry."); + } + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs new file mode 100644 index 000000000..4b0073301 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/ArchiveEntryNameTests.cs @@ -0,0 +1,75 @@ +using GenHub.Core.Utilities; + +namespace GenHub.Tests.Core.Utilities; + +/// +/// Tests the screening applied to archive entry names before they become filesystem paths. +/// +public class ArchiveEntryNameTests +{ + /// + /// Accepts the ordinary relative names archives are made of, including the traversal segments + /// that the containment check rather than this screen is responsible for. + /// + /// The entry name under test. + [Theory] + [InlineData("readme.txt")] + [InlineData("patch/readme.txt")] + [InlineData("patch\\readme.txt")] + [InlineData("Bob's Map/bob.map")] + [InlineData("patch/../readme.txt")] + [InlineData("../escaped.big")] + public void IsExtractable_AcceptsNamesThatCanNameAFile(string entryName) + { + Assert.True(ArchiveEntryName.IsExtractable(entryName)); + } + + /// + /// Refuses names that cannot name a file. These are the dangerous ones: combined with the + /// extraction directory they resolve to that directory itself, so the write would land on the + /// directory rather than inside it, and the containment check sees nothing wrong. + /// + /// The entry name under test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("/")] + [InlineData("patch/")] + [InlineData("patch\\")] + [InlineData("patch/ /readme.txt")] + [InlineData(".")] + [InlineData("..")] + [InlineData("patch/.")] + [InlineData("patch/..")] + public void IsExtractable_RefusesNamesThatCannotNameAFile(string? entryName) + { + Assert.False(ArchiveEntryName.IsExtractable(entryName)); + } + + /// + /// Refuses names the strictest supported host cannot represent, so an archive is extracted the + /// same way everywhere. The colon matters most: on NTFS it names an alternate data stream, which + /// writes content that ordinary directory listings never show. + /// + /// The entry name under test. + [Theory] + [InlineData("readme.txt:stream")] + [InlineData("patch/readme.txt:stream")] + [InlineData("bad|name.dat")] + [InlineData("badname.dat")] + [InlineData("bad?name.dat")] + [InlineData("bad*name.dat")] + [InlineData("bad\"name.dat")] + [InlineData("bad\u0001name.dat")] + [InlineData("trailing.")] + [InlineData("trailing ")] + [InlineData("CON")] + [InlineData("nul.txt")] + [InlineData("patch/LPT1.dat")] + public void IsExtractable_RefusesNamesTheStrictestHostCannotRepresent(string entryName) + { + Assert.False(ArchiveEntryName.IsExtractable(entryName)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs new file mode 100644 index 000000000..23b7b8033 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs @@ -0,0 +1,352 @@ +using System.IO.Compression; +using System.Text; +using GenHub.Core.Constants; +using GenHub.Core.Exceptions; +using GenHub.Core.Utilities; +using GenHub.Tests.Core.Infrastructure; +using SharpCompress.Archives; + +namespace GenHub.Tests.Core.Utilities; + +/// +/// Tests that archive entries are bounded by the bytes they actually expand to. +/// +public sealed class BoundedArchiveExtractorTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubBoundedExtractor", + Guid.NewGuid().ToString("N")); + + /// + /// Initializes a new instance of the class. + /// + public BoundedArchiveExtractorTests() + { + Directory.CreateDirectory(_workingDirectory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Writes the whole entry and reports the byte count when it fits inside both budgets. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_WritesEntryWithinBudgetAsync() + { + var payload = Encoding.UTF8.GetBytes("map contents"); + using var source = new MemoryStream(payload); + var destination = Path.Combine(_workingDirectory, "entry.dat"); + + var written = await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "entry.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024); + + Assert.Equal(payload.Length, written); + Assert.Equal(payload, await File.ReadAllBytesAsync(destination)); + } + + /// + /// Aborts and removes the partial output when an entry expands past its own cap. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEntryOverPerEntryCapAndDeletesPartialOutputAsync() + { + using var source = new MemoryStream(new byte[64 * 1024]); + var destination = Path.Combine(_workingDirectory, "bomb.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "bomb.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue)); + + Assert.Equal("bomb.dat", failure.EntryName); + Assert.Equal(1024, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + /// + /// Aborts when an entry fits its own cap but exhausts what remains of the archive-wide budget. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEntryOverRemainingAggregateBudgetAsync() + { + using var source = new MemoryStream(new byte[64 * 1024]); + var destination = Path.Combine(_workingDirectory, "aggregate.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "aggregate.dat", + maxEntryBytes: long.MaxValue, + remainingAggregateBytes: 2048)); + + Assert.Equal(2048, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + /// + /// Leaves an existing destination untouched when overwriting is not permitted. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_KeepsExistingFileWhenOverwriteNotAllowedAsync() + { + var destination = Path.Combine(_workingDirectory, "existing.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(Encoding.UTF8.GetBytes("replacement")); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "existing.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024)); + + Assert.Equal("original", await File.ReadAllTextAsync(destination)); + } + + /// + /// Leaves the existing destination untouched when an overwriting copy fails part-way through. + /// The replacement is staged beside the destination, so the only file removed is the one this + /// call wrote. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_KeepsExistingFileWhenOverwritingCopyFailsAsync() + { + var destination = Path.Combine(_workingDirectory, "replaced.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(new byte[64 * 1024]); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "replaced.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue, + overwrite: true)); + + Assert.Equal("original", await File.ReadAllTextAsync(destination)); + Assert.Equal([destination], Directory.GetFiles(_workingDirectory)); + } + + /// + /// Replaces the existing destination once an overwriting copy completes, leaving no staging + /// file behind. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ReplacesExistingFileWhenOverwriteAllowedAsync() + { + var destination = Path.Combine(_workingDirectory, "replaced.dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new MemoryStream(Encoding.UTF8.GetBytes("replacement")); + + var written = await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "replaced.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 1024, + overwrite: true); + + Assert.Equal("replacement".Length, written); + Assert.Equal("replacement", await File.ReadAllTextAsync(destination)); + Assert.Equal([destination], Directory.GetFiles(_workingDirectory)); + } + + /// + /// Rejects an entry once the archive-wide budget is spent, even when the entry is empty and so + /// never reaches the read loop where the running total is checked. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsEmptyEntryOnceAggregateBudgetIsSpentAsync() + { + using var source = new MemoryStream([]); + var destination = Path.Combine(_workingDirectory, "empty.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "empty.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 0)); + + Assert.Equal("empty.dat", failure.EntryName); + Assert.False(File.Exists(destination)); + } + + /// + /// Shrinks the archive-wide budget across the entries of one archive the way its callers do, so + /// an entry that fits its own cap comfortably is still refused once earlier entries have spent + /// what the archive was allowed. Only the surviving entries are left on disk. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ShrinksTheAggregateBudgetAcrossEntriesAsync() + { + const long aggregateBudget = 4096; + const long entryCap = 4096; + int[] entrySizes = [3000, 1000, 200]; + long expandedBytes = 0; + + for (var index = 0; index < entrySizes.Length - 1; index++) + { + using var source = new MemoryStream(new byte[entrySizes[index]]); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + Path.Combine(_workingDirectory, $"entry{index}.dat"), + $"entry{index}.dat", + entryCap, + aggregateBudget - expandedBytes); + } + + Assert.Equal(4000, expandedBytes); + + using var lastSource = new MemoryStream(new byte[entrySizes[^1]]); + var lastDestination = Path.Combine(_workingDirectory, "entry2.dat"); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + lastSource, + lastDestination, + "entry2.dat", + entryCap, + aggregateBudget - expandedBytes)); + + Assert.Equal(aggregateBudget - expandedBytes, failure.LimitBytes); + Assert.False(File.Exists(lastDestination)); + Assert.Equal(2, Directory.GetFiles(_workingDirectory).Length); + } + + /// + /// Names the exhausted budget rather than the entry when the archive had nothing left to spend, + /// so a diagnostic does not report an entry as expanding past a limit of zero bytes. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_ReportsASpentBudgetSeparatelyFromAnOversizedEntryAsync() + { + using var spent = new MemoryStream(new byte[16]); + using var oversized = new MemoryStream(new byte[64 * 1024]); + + var spentFailure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + spent, + Path.Combine(_workingDirectory, "spent.dat"), + "spent.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: 0)); + + var oversizedFailure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + oversized, + Path.Combine(_workingDirectory, "oversized.dat"), + "oversized.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue)); + + Assert.Contains("budget was already spent", spentFailure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("expanded past", spentFailure.Message, StringComparison.Ordinal); + Assert.Contains("expanded past the allowed 1024 bytes", oversizedFailure.Message, StringComparison.Ordinal); + } + + /// + /// Stages an overwriting write under a name of its own rather than one built from the + /// destination, so a destination close to the Windows path limit is not pushed past it by the + /// staging name alone. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_StagesUnderANameThatDoesNotGrowWithTheDestinationAsync() + { + var destination = Path.Combine(_workingDirectory, new string('n', 120) + ".dat"); + await File.WriteAllTextAsync(destination, "original"); + using var source = new DirectoryObservingStream(_workingDirectory, 64 * 1024); + + await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + source, + destination, + "long.dat", + maxEntryBytes: 1024, + remainingAggregateBytes: long.MaxValue, + overwrite: true)); + + var staged = Assert.Single(source.ObservedFiles.Where(file => file != destination).Distinct()); + Assert.EndsWith(IoConstants.StagingFileSuffix, staged, StringComparison.Ordinal); + Assert.True( + staged.Length < destination.Length, + $"the staging path '{staged}' is longer than the destination it replaces"); + } + + /// + /// Rejects an archive entry whose central-directory header understates its real size. The + /// archive claims four kilobytes and inflates to twelve megabytes, which is only visible while + /// decompressing, so the copy must abort mid-stream and leave no partial output behind. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CopyEntryToFileAsync_RejectsArchiveThatUnderstatesItsDeclaredSizeAsync() + { + const int actualBytes = 12 * 1024 * 1024; + const int declaredBytes = 4096; + const long entryCap = 1024 * 1024; + + var archivePath = Path.Combine(_workingDirectory, "spoofed.zip"); + ArchiveFixtures.CreateWithSpoofedEntrySize(archivePath, "bomb.dat", actualBytes, declaredBytes); + + using var archive = ArchiveFactory.OpenArchive(archivePath); + var entry = archive.Entries.First(e => !e.IsDirectory); + Assert.Equal(declaredBytes, entry.Size); + + var destination = Path.Combine(_workingDirectory, "bomb.extracted"); + await using var entryStream = entry.OpenEntryStream(); + + var failure = await Assert.ThrowsAsync(() => + BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destination, + entry.Key ?? string.Empty, + maxEntryBytes: entryCap, + remainingAggregateBytes: long.MaxValue)); + + Assert.Equal(entryCap, failure.LimitBytes); + Assert.False(File.Exists(destination)); + } + + private sealed class DirectoryObservingStream(string directory, int length) + : MemoryStream(new byte[length]) + { + public List ObservedFiles { get; } = []; + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObservedFiles.AddRange(Directory.GetFiles(directory)); + + return base.ReadAsync(buffer, cancellationToken); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index 9bc5bd496..4087be255 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -1,4 +1,5 @@ using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameInstallations; @@ -13,8 +14,6 @@ using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; using SharpCompress.Archives; -using SharpCompress.Archives.SevenZip; -using SharpCompress.Common; using System; using System.Collections.Generic; using System.IO; @@ -81,7 +80,9 @@ private static string GetContentCodeFromManifest(ContentManifest manifest) /// /// Extracts an archive (ZIP, 7z, etc.) asynchronously using SharpCompress. - /// Automatically detects format. + /// Automatically detects format. Catalog archives are third-party input, so every entry is + /// confined to and the archive is held to entry-count and + /// expansion budgets measured against the bytes actually decompressed. /// private static async Task ExtractArchiveAsync( string archivePath, @@ -89,7 +90,7 @@ private static async Task ExtractArchiveAsync( CancellationToken cancellationToken) { await Task.Run( - () => + async () => { var fileInfo = new FileInfo(archivePath); if (!fileInfo.Exists || fileInfo.Length == 0) @@ -97,18 +98,49 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var archive = ArchiveFactory.Open(fileInfo); - foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + using var archive = ArchiveFactory.OpenArchive(fileInfo); + var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + if (fileEntries.Count > CommunityOutpostConstants.MaxArchiveEntries) + { + throw new InvalidOperationException( + $"Archive contains too many entries ({fileEntries.Count} > {CommunityOutpostConstants.MaxArchiveEntries})."); + } + + long expandedBytes = 0; + + foreach (var entry in fileEntries) { cancellationToken.ThrowIfCancellationRequested(); - entry.WriteToDirectory( - extractPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true, - }); + if (!ArchiveEntryName.IsExtractable(entry.Key)) + { + throw new InvalidOperationException( + $"Archive entry '{entry.Key}' has a name that cannot be extracted to a file."); + } + + var destinationPath = Path.GetFullPath(Path.Combine(extractPath, entry.Key)); + if (!PathHelper.IsPathWithinDirectory(extractPath, destinationPath)) + { + throw new InvalidOperationException( + $"Zip slip vulnerability detected: entry '{entry.Key}' attempts to extract outside target directory."); + } + + var destinationDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + await using var entryStream = entry.OpenEntryStream(); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destinationPath, + entry.Key, + CommunityOutpostConstants.MaxEntryUncompressedBytes, + CommunityOutpostConstants.MaxAggregateUncompressedBytes - expandedBytes, + overwrite: true, + cancellationToken); } }, cancellationToken); @@ -269,6 +301,13 @@ public async Task> DeliverContentAsync( { await ExtractArchiveAsync(archivePath, extractPath, cancellationToken); } + catch (OperationCanceledException) + { + logger.LogInformation( + "Extraction of {Path} was cancelled; the downloaded archive is left in place", + archivePath); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to extract archive from {Path}", archivePath); @@ -386,6 +425,10 @@ await ProcessAndMergeDependencyBigFilesAsync( return OperationResult.CreateSuccess(primaryManifest); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to deliver Community Outpost content"); @@ -805,7 +848,7 @@ private async Task ProcessAndMergeDependencyBigFilesAsync( // Ignore cleanup errors } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to process dependency {Name}", dep.Name); } diff --git a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs index fc974fd03..fa5f87046 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentStorageService.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Storage; @@ -47,7 +48,7 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma try { var fullPath = Path.GetFullPath(Path.Combine(baseDirectory, file.RelativePath)); - if (!IsPathWithinDirectory(normalizedBase, fullPath)) + if (!PathHelper.IsPathWithinDirectory(normalizedBase, fullPath)) { return OperationResult.CreateFailure($"File {file.RelativePath} attempts path traversal outside base directory"); } @@ -72,7 +73,7 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma ? Path.GetFullPath(file.SourcePath) : Path.GetFullPath(Path.Combine(baseDirectory, file.SourcePath)); - if (!IsPathWithinDirectory(normalizedBase, fullSource)) + if (!PathHelper.IsPathWithinDirectory(normalizedBase, fullSource)) { return OperationResult.CreateFailure($"File {file.RelativePath} specifies SourcePath {file.SourcePath} which traverses outside base directory"); } @@ -88,15 +89,6 @@ private static OperationResult ValidateManifestSecurity(ContentManifest ma return OperationResult.CreateSuccess(true); } - private static bool IsPathWithinDirectory(string normalizedBase, string fullPath) - { - var relative = Path.GetRelativePath(normalizedBase, fullPath); - return !relative.Equals("..", StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) && - !Path.IsPathRooted(relative); - } - private static async Task CalculateFileHashAsync(string filePath, CancellationToken cancellationToken) { using var sha256 = SHA256.Create(); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index 5d8578b60..eed7c2a24 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; @@ -14,10 +15,10 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Utilities; using GenHub.Features.Content.Services.Publishers; using Microsoft.Extensions.Logging; using SharpCompress.Archives; -using SharpCompress.Common; namespace GenHub.Features.Content.Services.GitHub; @@ -162,6 +163,13 @@ await ExtractArchiveAsync( logger.LogInformation("Extracted {ArchiveFile}", Path.GetFileName(archiveFile)); File.Delete(archiveFile); } + catch (OperationCanceledException) + { + logger.LogInformation( + "Extraction of {ArchiveFile} was cancelled; the downloaded archive is left in place", + Path.GetFileName(archiveFile)); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to extract {ArchiveFile}", Path.GetFileName(archiveFile)); @@ -182,6 +190,10 @@ await ExtractArchiveAsync( // For content without archives, return original manifest return OperationResult.CreateSuccess(packageManifest); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to deliver GitHub content for manifest {ManifestId}", packageManifest.Id); @@ -245,17 +257,6 @@ private static bool IsArchiveFile(string filePath) ext == FileTypes.RarFileExtension; } - private static bool IsPathWithinDirectory(string normalizedBase, string fullPath) - { - var normalizedRoot = Path.GetFullPath(normalizedBase); - var normalizedTarget = Path.GetFullPath(fullPath); - var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); - return !relative.Equals("..", StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) && - !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) && - !Path.IsPathRooted(relative); - } - /// /// Handles extracted content by using publisher-specific factories to create manifests. /// May return multiple manifests if the publisher factory detects multi-variant content. @@ -365,7 +366,9 @@ private async Task> HandleExtractedContentAsync } /// - /// Extracts an archive file asynchronously to prevent UI blocking. + /// Extracts an archive file asynchronously to prevent UI blocking. Release archives are remote + /// input, so every entry is confined to and the archive is + /// held to entry-count and expansion budgets measured against the bytes actually decompressed. /// /// Path to the archive file. /// Directory to extract files to. @@ -379,21 +382,38 @@ private async Task ExtractArchiveAsync( CancellationToken cancellationToken) { await Task.Run( - () => + async () => { - using var archive = ArchiveFactory.Open(new FileInfo(archiveFile)); - int totalEntries = archive.Entries.Count(e => !e.IsDirectory); - int currentEntry = 0; + using var archive = ArchiveFactory.OpenArchive(new FileInfo(archiveFile)); + var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); + + if (fileEntries.Count > GitHubConstants.MaxArchiveEntries) + { + throw new InvalidOperationException( + $"Archive contains too many entries ({fileEntries.Count} > {GitHubConstants.MaxArchiveEntries})."); + } - foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + int totalEntries = fileEntries.Count; + int currentEntry = 0; + long expandedBytes = 0; + long expansionBudget = Math.Min( + GitHubConstants.MaxAggregateUncompressedBytes, + Math.Max( + GitHubConstants.MinArchiveExpansionBudgetBytes, + new FileInfo(archiveFile).Length * GitHubConstants.MaxArchiveExpansionRatio)); + + foreach (var entry in fileEntries) { - if (cancellationToken.IsCancellationRequested) + cancellationToken.ThrowIfCancellationRequested(); + + if (!ArchiveEntryName.IsExtractable(entry.Key)) { - break; + throw new InvalidOperationException( + $"Archive entry '{entry.Key}' has a name that cannot be extracted to a file."); } - var destinationPath = Path.GetFullPath(Path.Combine(targetDirectory, entry.Key ?? string.Empty)); - if (!IsPathWithinDirectory(targetDirectory, destinationPath)) + var destinationPath = Path.GetFullPath(Path.Combine(targetDirectory, entry.Key)); + if (!PathHelper.IsPathWithinDirectory(targetDirectory, destinationPath)) { throw new InvalidOperationException($"Zip slip vulnerability detected: entry '{entry.Key}' attempts to extract outside target directory."); } @@ -404,13 +424,17 @@ await Task.Run( Directory.CreateDirectory(destinationDir); } - entry.WriteToFile( - destinationPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true, - }); + await using (var entryStream = entry.OpenEntryStream()) + { + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + entryStream, + destinationPath, + entry.Key, + GitHubConstants.MaxEntryUncompressedBytes, + expansionBudget - expandedBytes, + overwrite: true, + cancellationToken); + } currentEntry++; diff --git a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs index a371e17bd..64486684b 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/Services/MapImportService.cs @@ -2,6 +2,7 @@ using GenHub.Core.Interfaces.Tools.MapManager; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -23,7 +24,7 @@ public sealed class MapImportService( MapNameParser mapNameParser, ILogger logger) : IMapImportService { - private static readonly char[] PathSeparators = ['/', '\'']; + private static readonly char[] PathSeparators = ['/', '\\']; /// public async Task ImportFromUrlAsync( @@ -97,7 +98,7 @@ public async Task ImportFromUrlAsync( result = await ImportFromFilesAsync([tempPath], targetVersion, ct); } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to import from URL: {Url}", url); result.Errors.Add($"Import failed: {ex.Message}"); @@ -207,7 +208,7 @@ public async Task ImportFromFilesAsync( }; result.ImportedMaps.Add(mapFile); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to import file: {FilePath}", filePath); result.Errors.Add($"Failed to import {Path.GetFileName(filePath)}: {ex.Message}"); @@ -226,7 +227,7 @@ public async Task ImportFromZipAsync( CancellationToken ct = default) { return await Task.Run( - () => + async () => { var result = new ImportResult(); var (isValid, errorMessage) = ValidateZip(zipPath); @@ -256,6 +257,7 @@ public async Task ImportFromZipAsync( int totalMaps = 0; int processedMaps = 0; + long expandedBytes = 0; // Count total maps for progress foreach (var group in entriesByDirectory) @@ -271,6 +273,8 @@ public async Task ImportFromZipAsync( foreach (var mapEntry in mapEntries) { + ct.ThrowIfCancellationRequested(); + if (mapEntry.Length > IMapImportService.MaxMapSizeBytes) { result.Errors.Add($"Map too large: {mapEntry.Name}"); @@ -288,39 +292,72 @@ public async Task ImportFromZipAsync( } var mapDirPath = GetUniqueDirectoryPath(Path.Combine(targetDir, mapDirName)); - Directory.CreateDirectory(mapDirPath); - - // Extract the .map file var mapDestPath = Path.Combine(mapDirPath, mapEntry.Name); - mapEntry.ExtractToFile(mapDestPath, false); - var assetFiles = new List(); string? thumbnailPath = null; - // Extract related asset files from the same directory in the ZIP - if (!string.IsNullOrEmpty(directoryName)) + long mapExpandedBytes = 0; + + try { - var assetEntries = entries.Where(e => - !e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase) && - MapManagerConstants.AllowedExtensions.Contains(Path.GetExtension(e.Name), StringComparer.OrdinalIgnoreCase)); + Directory.CreateDirectory(mapDirPath); - foreach (var assetEntry in assetEntries) + await using (var mapStream = mapEntry.Open()) { - var assetDestPath = Path.Combine(mapDirPath, assetEntry.Name); - if (!File.Exists(assetDestPath)) - { - assetEntry.ExtractToFile(assetDestPath, false); - } + mapExpandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + mapStream, + mapDestPath, + mapEntry.FullName, + IMapImportService.MaxMapSizeBytes, + MapManagerConstants.MaxAggregateUncompressedBytes - expandedBytes - mapExpandedBytes, + cancellationToken: ct); + } - assetFiles.Add(assetDestPath); + // Extract related asset files from the same directory in the ZIP + if (!string.IsNullOrEmpty(directoryName)) + { + var assetEntries = entries.Where(e => + !e.Name.EndsWith(".map", StringComparison.OrdinalIgnoreCase) && + MapManagerConstants.AllowedExtensions.Contains(Path.GetExtension(e.Name), StringComparer.OrdinalIgnoreCase)); - // Check for thumbnail - if (assetEntry.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase) || - (thumbnailPath == null && assetEntry.Name.EndsWith(".tga", StringComparison.OrdinalIgnoreCase))) + foreach (var assetEntry in assetEntries) { - thumbnailPath = assetDestPath; + var assetDestPath = Path.Combine(mapDirPath, assetEntry.Name); + if (!File.Exists(assetDestPath)) + { + await using var assetStream = assetEntry.Open(); + mapExpandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + assetStream, + assetDestPath, + assetEntry.FullName, + MapManagerConstants.MaxAssetSizeBytes, + MapManagerConstants.MaxAggregateUncompressedBytes - expandedBytes - mapExpandedBytes, + cancellationToken: ct); + } + + assetFiles.Add(assetDestPath); + + // Check for thumbnail + if (assetEntry.Name.Equals(MapManagerConstants.DefaultThumbnailName, StringComparison.OrdinalIgnoreCase) || + (thumbnailPath == null && assetEntry.Name.EndsWith(".tga", StringComparison.OrdinalIgnoreCase))) + { + thumbnailPath = assetDestPath; + } } } + + expandedBytes += mapExpandedBytes; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning( + "Discarding map {Entry} from {ZipPath}: {Reason}", + mapEntry.FullName, + zipPath, + ex.Message); + result.Errors.Add(ex.Message); + DeleteDirectoryBestEffort(mapDirPath); + continue; } var totalSize = new FileInfo(mapDestPath).Length + assetFiles.Sum(f => new FileInfo(f).Length); @@ -352,6 +389,11 @@ public async Task ImportFromZipAsync( progress?.Report(1.0); } + catch (OperationCanceledException) + { + logger.LogInformation("Import from ZIP was cancelled: {ZipPath}", zipPath); + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to import from ZIP: {ZipPath}", zipPath); @@ -543,6 +585,25 @@ private static string ExtractFileName(Uri uri, HttpResponseMessage response) return $"map_{Guid.NewGuid():N}.zip"; } + private static void DeleteDirectoryBestEffort(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + // Best effort cleanup + } + catch (UnauthorizedAccessException) + { + // Best effort cleanup + } + } + private static string GetUniqueFilePath(string path) { if (!File.Exists(path)) diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs index 2b6540a4d..2e5b99364 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Services/ReplayImportService.cs @@ -11,6 +11,7 @@ using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.Tools.ReplayManager.Services; @@ -171,7 +172,7 @@ public async Task ImportFromFilesAsync( skipped++; } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { errors.Add($"Failed to import {Path.GetFileName(path)}: {ex.Message}"); skipped++; @@ -218,25 +219,45 @@ public async Task ImportFromZipAsync( var entries = archive.Entries.Where(e => !string.IsNullOrEmpty(e.Name)).ToList(); int total = entries.Count; int count = 0; + long expandedBytes = 0; + + directoryService.EnsureDirectoryExists(targetVersion); + var targetDir = directoryService.GetReplayDirectory(targetVersion); foreach (var entry in entries) { + ct.ThrowIfCancellationRequested(); + count++; progress?.Report((double)count / total); - using var stream = entry.Open(); - var result = await ImportFromStreamAsync(stream, entry.Name, targetVersion, ct); - if (result.Success) + var targetPath = GetUniquePath(Path.Combine(targetDir, Path.GetFileName(entry.Name))); + + try { - imported.AddRange(result.ImportedFiles); + await using var stream = entry.Open(); + expandedBytes += await BoundedArchiveExtractor.CopyEntryToFileAsync( + stream, + targetPath, + entry.FullName, + ReplayManagerConstants.MaxReplaySizeBytes, + ReplayManagerConstants.MaxAggregateUncompressedBytes - expandedBytes, + cancellationToken: ct); + imported.Add(targetPath); } - else + catch (Exception ex) when (ex is not OperationCanceledException) { - errors.AddRange(result.Errors); + logger.LogWarning(ex, "Discarding replay entry {Entry} from {ZipPath}", entry.FullName, zipPath); + errors.Add(ex.Message); skipped++; } } } + catch (OperationCanceledException) + { + logger.LogInformation("Import from ZIP {ZipPath} was cancelled", zipPath); + throw; + } catch (Exception ex) { logger.LogError(ex, LogMessages.FailedToImportFromZip, zipPath); @@ -279,7 +300,7 @@ public async Task ImportFromStreamAsync( ImportedFiles = [targetPath], }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, LogMessages.FailedToImportStream, fileName); return new ImportResult { Success = false, FilesImported = 0, FilesSkipped = 1, Errors = [ex.Message] }; diff --git a/docs/dev/constants.md b/docs/dev/constants.md index d0c884cd8..052e8dcf4 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -831,6 +831,7 @@ public static string FromInstallationType(GameInstallationType installationType) ## IoConstants Class - `DefaultFileBufferSize`: 4096 +- `StagingFileSuffix`: ".genhub-staging" --- From f3823cc4e4a1dc7cea98cb0faa4559d58951e76b Mon Sep 17 00:00:00 2001 From: Bobby Battista Date: Wed, 19 Aug 2026 12:44:55 -0400 Subject: [PATCH 12/20] test(launching): add engine-only launch smoke test and macos-15 CI job (#353) * test(launching): add engine-only launch smoke test and macos-15 CI job * test(launching): assert the no-data abort writes its crash report under the sandboxed HOME * ci: include engine-launch-smoke in the build summary needs * test(launching): pin retail install-path variables and assert the crash report's reason line * ci: fail the build summary on failed jobs and render skipped jobs as skipped * ci: include detect-changes in the build summary gate * ci: unwrap the engine archive's top-level directory in the smoke test * ci: drop the unused checkout from the build summary job * ci: include build configuration files in change detection --- .github/workflows/ci.yml | 95 ++++++- .../GameProfiles/EngineLaunchSmokeTests.cs | 237 ++++++++++++++++++ 2 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b5f3ea9..f220afae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,8 @@ jobs: - '**/*.axaml' - '**/*.csproj' - '**/*.sln' + - '**/*.props' + - '**/*.targets' - '.github/workflows/**' - name: Changes Summary @@ -535,20 +537,99 @@ jobs: if-no-files-found: ignore retention-days: 7 + # Launches the real native engine with no game data at all and requires the abort the + # engine is known to produce (exit code 1 once INI loading finds nothing to read). No + # licensed retail content is involved; what this covers is everything before that + # point — the binary loads, its dylibs resolve, and startup fails fast instead of + # hanging. No other job executes the native launch path at all. + engine-launch-smoke: + name: Engine Launch Smoke Test + needs: detect-changes + if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' }} + # macos-14 and newer are Apple Silicon, matching the arm64 engine asset. + runs-on: macos-15 + timeout-minutes: 15 + + steps: + - name: Checkout Code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Cache NuGet Packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + # `latest-bgfx` is a moving tag, republished as the engine advances. Deliberate + # tradeoff: this job tracks the current engine build rather than pinning a + # reproducible one, so an engine regression surfaces here first. + - name: Download Native Engine + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download latest-bgfx \ + --repo bobtista/GeneralsGameCode \ + --pattern 'GeneralsZH-macos-arm64.zip' \ + --dir engine-download + # The archive wraps everything in a GeneralsZH-macos-arm64/ directory, so it is + # staged and the wrapper's contents lifted out. Not `unzip -j`: that would also + # flatten Data/INI/, which the engine reads by path. + unzip -q engine-download/GeneralsZH-macos-arm64.zip -d engine-staging + root="$(find engine-staging -mindepth 1 -maxdepth 1 -type d)" + if [ ! -f "$root/generalszh" ]; then + echo "::error::generalszh not found in the release archive; contents were:" + find engine-staging -maxdepth 2 + exit 1 + fi + mv "$root" native-client + chmod +x native-client/generalszh + ls -l native-client + + # GENHUB_REQUIRE_NATIVE_SMOKE turns "no client found" from a silent skip into a + # failure. Without it a broken download would leave the test skipping and this + # job permanently, meaninglessly green. + - name: Run Engine Launch Smoke Test + env: + GENHUB_NATIVE_CLIENT_DIR: ${{ github.workspace }}/native-client + GENHUB_REQUIRE_NATIVE_SMOKE: '1' + run: | + dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj \ + -c ${{ env.BUILD_CONFIGURATION }} \ + --filter "FullyQualifiedName~EngineLaunchSmokeTests" \ + --verbosity normal + summary: name: Build Summary - needs: [build-windows, build-linux, build-macos] + # detect-changes is in `needs` so its own failure reaches the gate below. Without it a + # failed detect-changes skips every build, and all-skipped reads as a clean pass. + needs: [detect-changes, build-windows, build-linux, build-macos, engine-launch-smoke] if: always() runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - + # A job skipped by detect-changes gating is a legitimate outcome, rendered as + # such rather than as a failure. - name: Generate Summary run: | echo "### 🚀 GenHub Build Results" >> $GITHUB_STEP_SUMMARY echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY - echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || needs.build-windows.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || needs.build-linux.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || needs.build-macos.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Engine Smoke | ${{ needs.engine-launch-smoke.result == 'success' && '✅ Passed' || needs.engine-launch-smoke.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY + + # Turns the summary into a real gate: branch protection can require this one + # check and a failed or cancelled job anywhere in `needs` blocks the merge. + # Skipped jobs pass — being gated off by detect-changes is not a failure. + - name: Fail when a required job failed + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: exit 1 diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs new file mode 100644 index 000000000..200992255 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs @@ -0,0 +1,237 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Engine-only launch smoke test: starts the native client with no game data at all and +/// requires the failure the engine is known to produce. +/// +/// The engine cannot reach its main loop without content — with no readable INI it aborts +/// with exit code 1 during initialisation. Launching it in an empty workspace therefore +/// still proves the things CI otherwise never covers: the binary loads, its dylibs resolve +/// relative to the executable, initialisation runs as far as INI loading, and the failure +/// is a prompt exit rather than a hang. No licensed retail data is involved. +/// +/// +/// Like the other native-client tests this skips when no client is present — unless +/// GENHUB_REQUIRE_NATIVE_SMOKE is set, which CI uses to turn a missing client into +/// a failure instead of a silent green run. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class EngineLaunchSmokeTests : IDisposable +{ + /// + /// Environment variable that forbids skipping: when set to 1 or true, a + /// missing native client fails the test rather than passing it vacuously. + /// + public const string RequireEnvironmentVariable = "GENHUB_REQUIRE_NATIVE_SMOKE"; + + /// + /// How long the engine gets to exit before the test declares a hang. The observed + /// failure takes about a second; the margin covers a cold CI runner, not the engine. + /// + private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(60); + + private readonly string _tempRoot = Path.Combine( + Path.GetTempPath(), + $"genhub-engine-smoke-{Guid.NewGuid():N}"); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Initializes a new instance of the class. + /// + public EngineLaunchSmokeTests() => Directory.CreateDirectory(_tempRoot); + + private static bool IsSmokeRequired + { + get + { + var value = Environment.GetEnvironmentVariable(RequireEnvironmentVariable); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// Stages the engine binary and its libraries into an empty workspace — no archives, + /// no retail roots — and launches headless with HOME redirected so the crash report + /// lands in the sandbox. The engine must exit with code 1 and leave its crash report + /// in the redirected HOME — the diagnostic that identifies this as the known abort. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EngineWithNoGameData_ExitsWithCodeOne() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + var missingClientMessage = + $"{RequireEnvironmentVariable} is set but no native client was found. " + + $"Point {NativeClientFixture.EnvironmentOverride} at a directory containing " + + $"'{NativeClientFixture.BinaryName}'."; + Assert.False(IsSmokeRequired, missingClientMessage); + return; + } + + var workspace = StageEngineOnlyWorkspace(installDirectory); + var sandboxHome = Path.Combine(_tempRoot, "home"); + Directory.CreateDirectory(sandboxHome); + + // The exit code is only observable through the manager's exit event: the process + // handle stays internal, and GetProcessInfoAsync reports an exited process as + // not found. Subscribed before launch so a fast exit cannot slip past. + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _processManager.ProcessExited += (_, e) => exited.TrySetResult(e.ExitCode); + + // The install-path variables are pinned to the empty workspace so a developer who + // has them exported cannot feed this "no data" launch their real retail content + // through the inherited environment. GameProcessManager assigns these into + // ProcessStartInfo.EnvironmentVariables by indexer, which the framework + // pre-populates from the parent environment — so an inherited value is replaced, + // not merely joined. The trailing separator matches how GameLauncher sets these + // for real launches: the engine requires it on the value. + var pinnedInstallPath = workspace + Path.DirectorySeparatorChar; + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(workspace, NativeClientFixture.BinaryName), + WorkingDirectory = workspace, + Arguments = new() { ["-headless"] = string.Empty }, + EnvironmentVariables = new() + { + ["HOME"] = sandboxHome, + [RetailArchiveConstants.ZeroHourInstallPathVariable] = pinnedInstallPath, + [RetailArchiveConstants.GeneralsInstallPathVariable] = pinnedInstallPath, + }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + + if (!result.Success) + { + // The engine beat the launcher-detection delay. The manager folds the exit + // code into the error, so the assertion still pins it to exactly 1. + Assert.Contains( + "exited immediately with code 1", + string.Join(" ", result.Errors), + StringComparison.OrdinalIgnoreCase); + } + else + { + var completed = await Task.WhenAny(exited.Task, Task.Delay(ExitTimeout)); + if (completed != exited.Task) + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + Assert.Fail( + $"The engine was still running {ExitTimeout.TotalSeconds:F0}s after launch with " + + "no game data. The known behaviour is a prompt abort with exit code 1; a hang " + + "here means startup no longer fails fast and the launcher could wait forever."); + } + + var exitCode = await exited.Task; + Assert.NotNull(exitCode); + Assert.Equal(1, exitCode); + } + + AssertCrashReportWasWritten(sandboxHome); + } + + /// + /// Releases the temporary workspace and sandbox HOME. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + _processManager.Dispose(); + try + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + + /// + /// Asserts the abort produced its diagnostic. In this failure mode stderr is empty; + /// what the engine leaves behind is a crash report named ReleaseCrashInfo.txt + /// under HOME (on macOS beneath Library/Application Support). Searched + /// recursively so the intermediate segments — engine behaviour, and platform + /// dependent — are not hardcoded. The sandbox HOME is created empty by this test, so + /// any report found here was newly written by this launch; finding it in the sandbox + /// also proves the HOME redirection worked, keeping the user's real profile untouched. + /// + /// The redirected HOME directory. + private static void AssertCrashReportWasWritten(string sandboxHome) + { + var reports = Directory + .EnumerateFiles(sandboxHome, "ReleaseCrashInfo.txt", SearchOption.AllDirectories) + .ToList(); + + var missingReportMessage = + "The engine exited with code 1 but wrote no ReleaseCrashInfo.txt under the " + + $"redirected HOME '{sandboxHome}'. The known abort writes that report before " + + "exiting, so its absence means this was a different failure than the " + + "no-game-data INI abort this test pins down."; + Assert.True(reports.Count > 0, missingReportMessage); + + var reportContents = File.ReadAllText(reports[0]); + Assert.False( + string.IsNullOrWhiteSpace(reportContents), + $"The crash report at '{reports[0]}' is empty; the known abort records its reason."); + + // The stable line the abort writes is "; Reason Uncaught Exception during + // initialization." — asserted without the leading punctuation so a formatting + // change there cannot break the test, while the reason itself stays pinned. + Assert.Contains( + "Reason Uncaught Exception during initialization.", + reportContents, + StringComparison.Ordinal); + } + + /// + /// Copies only the engine binary and its dynamic libraries into a fresh directory. + /// Everything else in the source install — archives, retail roots, user files — is + /// deliberately left behind; their absence is the point of the test. + /// + /// The native client install to stage from. + /// The staged workspace directory. + private string StageEngineOnlyWorkspace(string installDirectory) + { + var workspace = Path.Combine(_tempRoot, "workspace"); + Directory.CreateDirectory(workspace); + + foreach (var path in Directory.EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(path); + if (name != NativeClientFixture.BinaryName && !NativeClientFixture.IsDynamicLibrary(name)) + { + continue; + } + + File.Copy(path, Path.Combine(workspace, name)); + } + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + Path.Combine(workspace, NativeClientFixture.BinaryName), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return workspace; + } +} From 296ab13c9dffc0b05fe965ce42535a6e52586e3b Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:58:07 +0200 Subject: [PATCH 13/20] feat(info): explain workspace strategies, file linking differences, and permissions (#404) --- .../Info/DefaultInfoContentProviderTests.cs | 73 ++++++++++++ .../Services/DefaultInfoContentProvider.cs | 109 +++++++++++++++--- .../Info/Views/GenHubInfoSectionView.axaml | 2 +- 3 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs new file mode 100644 index 000000000..f61531fd4 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Info/DefaultInfoContentProviderTests.cs @@ -0,0 +1,73 @@ +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Interfaces.Info; +using GenHub.Features.Info.Services; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Info; + +/// +/// Unit tests for . +/// +public class DefaultInfoContentProviderTests +{ + private readonly Mock _patchNotesServiceMock = new(); + private readonly DefaultInfoContentProvider _provider; + + /// + /// Initializes a new instance of the class. + /// + public DefaultInfoContentProviderTests() + { + _provider = new DefaultInfoContentProvider(_patchNotesServiceMock.Object); + } + + /// + /// Verifies that GetAllSectionsAsync returns all expected info sections. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task GetAllSectionsAsync_ReturnsOrderedSectionsAsync() + { + var sections = (await _provider.GetAllSectionsAsync()).ToList(); + + sections.Should().NotBeEmpty(); + sections.Should().Contain(s => s.Id == "workspaces"); + sections.Should().Contain(s => s.Id == "quickstart"); + } + + /// + /// Verifies that GetSectionAsync returns the workspace section with comprehensive strategy explanations. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task GetSectionAsync_WorkspaceSection_ContainsComprehensiveStrategyExplanationsAsync() + { + var section = await _provider.GetSectionAsync("workspaces"); + + section.Should().NotBeNull(); + section!.Title.Should().Be("Virtual Workspaces"); + section.Cards.Should().NotBeEmpty(); + + var titles = section.Cards.Select(c => c.Title).ToList(); + titles.Should().Contain("The Magic Mirror"); + titles.Should().Contain("Workspace Strategies Compared"); + titles.Should().Contain("Hardlinks vs Symlinks vs Copies: Deep Dive"); + titles.Should().Contain("Troubleshooting & Permissions"); + titles.Should().Contain("Performance Specs"); + + var comparisonCard = section.Cards.First(c => c.Title == "Workspace Strategies Compared"); + comparisonCard.DetailedContent.Should().Contain("HardLink"); + comparisonCard.DetailedContent.Should().Contain("SymlinkOnly"); + comparisonCard.DetailedContent.Should().Contain("HybridCopySymlink"); + comparisonCard.DetailedContent.Should().Contain("FullCopy"); + + var deepDiveCard = section.Cards.First(c => c.Title == "Hardlinks vs Symlinks vs Copies: Deep Dive"); + deepDiveCard.DetailedContent.Should().Contain("Hardlink"); + deepDiveCard.DetailedContent.Should().Contain("Symlink"); + deepDiveCard.DetailedContent.Should().Contain("Full Copy"); + deepDiveCard.DetailedContent.Should().Contain("Automatic Fallback"); + } +} diff --git a/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs b/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs index 0daa3e0d6..7e7a2fcf7 100644 --- a/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs +++ b/GenHub/GenHub/Features/Info/Services/DefaultInfoContentProvider.cs @@ -788,7 +788,7 @@ private static InfoSection CreateWorkspaceSection() { Id = "workspaces", Title = "Virtual Workspaces", - Description = "Technical details of NTFS Hardlink isolation.", + Description = "Workspace strategies, file linking techniques, and isolation mechanics.", Order = 8, Cards = [ @@ -800,36 +800,117 @@ private static InfoSection CreateWorkspaceSection() IsExpandable = true, DetailedContent = """ **The "Magic Mirror":** - When you hit Play, GenHub creates a "Virtual Copy" of your game installation instantly. + When you hit Play, GenHub creates an isolated virtual workspace for your profile (taking milliseconds in linked modes). **Why is this cool?** - 1. **Zero Space:** It looks like a full 5GB game, but it takes up 0MB of disk space on your drive. - 2. **Safety:** Any changes made by mods happen in this "Mirror". If a mod breaks the game, your actual installation is perfectly safe. + 1. **Zero Space:** In linked modes (HardLink and SymlinkOnly), it acts like a full multi-gigabyte game folder while consuming virtually 0 MB of extra disk space. + 2. **Profile Isolation:** Mods and configurations live in dedicated profile workspaces without manually shuffling files in your main game directory. (Note: In direct linked modes, file data is shared with the underlying source; choose Hybrid or Full Copy if mods modify game binaries in-place). + 3. **Instant Mod Switching:** Switch between massive total conversions like *Rise of the Reds* and *ShockWave* without reinstalling or moving files. """, }, new InfoCard { - Title = "Troubleshooting", - Content = "Resolving common build errors.", + Title = "Workspace Strategies Compared", + Content = "Comparing Hardlink, Symlink, Hybrid, and Full Copy strategies.", + Type = InfoCardType.Concept, + IsExpandable = true, + DetailedContent = """ + **Choosing the Right Strategy:** + GenHub supports four file deployment strategies under **Settings -> Game Configuration**: + + * **HardLink (Default & Recommended):** + * *How it works:* Creates direct filesystem pointers (hard links) on the same drive. If the workspace is on a different drive than the game installation, it automatically falls back to copying files. + * *Disk Space:* **0 bytes** extra storage when on the same drive (full file size if copying across drives). + * *Speed:* Instant (< 50ms) on the same volume. + * *Privileges:* No administrator privileges or developer mode needed. + * *Recommendation:* Place workspaces and game files on the **same drive/volume** (e.g. both on `C:` or both on `D:`) for optimal zero-space operation. + + * **SymlinkOnly:** + * *How it works:* Creates symbolic link pointers referencing target files and directories. + * *Disk Space:* **Negligible** (~few KB of pointer metadata). + * *Speed:* Instant (< 50ms). + * *Advantage:* Links seamlessly across **different drives and partitions**. + * *Limitation:* On Windows, requires **Administrator rights** or **Developer Mode** enabled in Windows Settings. + + * **HybridCopySymlink (Balanced Compatibility):** + * *How it works:* Copies essential engine files, scripts, and mod configurations into the workspace while symlinking non-essential media assets (such as textures, audio, and video). + * *Disk Space:* Balanced (copies essential assets, links media assets). + * *Speed:* Fast (1-2 seconds). + * *Advantage:* Protects essential configs from cross-profile conflicts while reducing overall workspace footprint. + + * **FullCopy (Universal Fallback):** + * *How it works:* Physically duplicates every game and mod file into the workspace directory. + * *Disk Space:* Uses full game size (**2-5+ GB** per profile). + * *Speed:* Slower (10-30+ seconds depending on drive speed). + * *Advantage:* Unconditional compatibility across external drives, network drives, and restricted environments. + """, + }, + new InfoCard + { + Title = "Hardlinks vs Symlinks vs Copies: Deep Dive", + Content = "How file linking differs under the hood.", + Type = InfoCardType.Feature, + IsExpandable = true, + DetailedContent = """ + **Under the Hood:** + + * **Hardlink:** + A hardlink is a directory entry that points directly to an existing file's data cluster on disk (the NTFS file record / inode). The file data is shared, so creating a hardlink takes zero disk space. Because it points directly to physical drive sectors, hardlinks cannot cross drive partitions. + + * **Symlink (Symbolic Link):** + A symlink is a special small file that contains a text path pointing to another file or folder (like a transparent shortcut at the operating system level). Because it stores a path, it can point across different drives, but Windows security policies require elevated privileges or Developer Mode to create symlinks. + + * **Full Copy:** + A physical byte-for-byte duplicate of the source file. It allocates new disk clusters and writes the entire file contents again. + + **Automatic Fallback:** + If you configure Symlink mode but run GenHub without administrator rights or Developer Mode, GenHub automatically falls back to hardlinks when files reside on the same drive, ensuring your game launches seamlessly without interruptions. + """, + }, + new InfoCard + { + Title = "Troubleshooting & Permissions", + Content = "Resolving common permissions and workspace build errors.", Type = InfoCardType.HowTo, IsExpandable = true, DetailedContent = """ - **Common Issues:** - - **"Access Denied":** GenHub requires Write permissions to `AppData`. Run as Admin if issues persist. - - **"File In Use":** Ensure the game process is fully terminated before rebuilding. + **Common Issues & Solutions:** + + * **"Access Denied" / Privilege Errors:** + * If using Symlink strategy on Windows, enable **Developer Mode** in *Windows Settings -> System -> For developers*, or run GenHub as Administrator. + * Alternatively, switch your Default Workspace Strategy to **HardLink** in GenHub Settings. + * **Cross-Drive Linking & Storage:** + * Hardlinks require the same drive/volume to achieve zero-space linking; across different drives, HardLink strategy falls back to copying files. + * To maintain instant, zero-space workspaces, keep your CAS pool and workspace directories on the same drive as your game installation in **Settings -> Data Directories**, or enable Symlink mode with Developer Mode turned on. + * **"File In Use" / Locked Files:** + * Ensure all instances of `generals.exe` or `game.dat` are completely closed before switching profiles or rebuilding workspaces. """, }, new InfoCard { Title = "Performance Specs", - Content = "Efficiency and integrity metrics.", + Content = "Efficiency, speed, and integrity metrics across strategies.", Type = InfoCardType.Feature, IsExpandable = true, DetailedContent = """ - **Hardlinks:** - - **Speed:** < 50ms creation time (Metadata only). - - **Space:** 0 bytes additional disk usage (Pointers). - - **Integrity:** Read-only source files. Modifications in workspace do not corrupt the installation. + **Strategy Metrics:** + + * **HardLink:** + * *Creation Time:* < 50ms on same volume (Metadata only) + * *Disk Overhead:* 0 MB on same volume (copies on cross-volume) + * *Integrity:* Shared data clusters (CAS objects remain immutable in CAS pool; direct writes affect linked file). + * **SymlinkOnly:** + * *Creation Time:* < 50ms (Pointer creation) + * *Disk Overhead:* < 1 MB + * *Integrity:* Transparent pointer redirection across volumes. + * **Hybrid:** + * *Creation Time:* 1-2 seconds + * *Disk Overhead:* Copies essential configs, links media assets + * *Integrity:* Physical copies for essential configs, shared links for media assets. + * **Full Copy:** + * *Creation Time:* 10-30 seconds + * *Disk Overhead:* Full size (2,000 - 5,000+ MB) + * *Integrity:* Total physical file isolation. """, }, ], diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index 62f0318db..805271908 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -733,7 +733,7 @@ - + From cbb87a941b459dd1f4e29b3699f157ee17b77bfe Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:59:31 +0200 Subject: [PATCH 14/20] feat(update): implement parallel range chunk downloader for Velopack (#401) --- .agents/skills/babysit-pr/SKILL.md | 7 +- .claude/skills/babysit-pr/SKILL.md | 7 +- AGENTS.md | 4 +- .../Constants/AppUpdateConstants.cs | 20 + .../Constants/AppUpdateConstantsTests.cs | 14 + .../FastHttpClientFileDownloaderTests.cs | 535 ++++++++++++++++++ .../Services/VelopackUpdateManagerTests.cs | 23 + .../GameProfiles/GameProcessManagerTests.cs | 9 +- .../Services/FastHttpClientFileDownloader.cs | 340 +++++++++++ .../Services/VelopackUpdateManager.cs | 126 +---- .../DependencyInjection/AppUpdateModule.cs | 5 + 11 files changed, 981 insertions(+), 109 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs create mode 100644 GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs diff --git a/.agents/skills/babysit-pr/SKILL.md b/.agents/skills/babysit-pr/SKILL.md index d65990f54..67be95e99 100644 --- a/.agents/skills/babysit-pr/SKILL.md +++ b/.agents/skills/babysit-pr/SKILL.md @@ -108,10 +108,9 @@ Address all actionable items in a single systematic pass: 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. +3. **Resolve Threads (No Bot Comment Noise):** + - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments. + - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed. --- diff --git a/.claude/skills/babysit-pr/SKILL.md b/.claude/skills/babysit-pr/SKILL.md index d65990f54..67be95e99 100644 --- a/.claude/skills/babysit-pr/SKILL.md +++ b/.claude/skills/babysit-pr/SKILL.md @@ -108,10 +108,9 @@ Address all actionable items in a single systematic pass: 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. +3. **Resolve Threads (No Bot Comment Noise):** + - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments. + - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed. --- diff --git a/AGENTS.md b/AGENTS.md index 6ce64b053..8c23bb9b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,5 +162,5 @@ This repository uses **GitNexus** to maintain an AST-parsed structural knowledge - Conventional commit titles, plain language: `fix(core): CAS pool pruning handles locked files`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. -- One concern per PR. If the description says "also", split it. -- When babysitting: poll checks and all bot comments (including inline review threads and summary 'Outside diff range' findings) newer than the last push. Verify each finding against the source, fix real ones in code, and reply to review threads with a clear technical reason before resolving the discussion on GitHub. For extended PR workflows, invoke the `pull-request` and `babysit-pr` skills. Stay quiet when nothing is new. Stop when all checks pass on the latest commit with all threads resolved. +- **Never push while checks are running:** NEVER push new commits while CI workflows, platform builds (Windows, Linux, macOS), tests, DeepSource analyzers, or AI bot reviews (CodeRabbit, Kilo) are in progress or queued. Always wait until EVERY check run reaches `status == completed`. Consolidate all fixes and review resolutions into a single pass before pushing. +- When babysitting: poll checks and all bot comments (including inline review threads and summary 'Outside diff range' findings) newer than the last push. Verify each finding against the source and fix real ones in code. For automated bot threads (DeepSource, Qodo, CodeRabbit, etc.), resolve the discussion directly without posting reply comments; only reply to human maintainers if discussion or clarification is needed. For extended PR workflows, invoke the `pull-request` and `babysit-pr` skills. Stay quiet when nothing is new. Stop when all checks pass on the latest commit with all threads resolved. diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs index 5ee0581e1..eae8e7458 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -263,6 +263,26 @@ public static class AppUpdateConstants /// public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5; + /// + /// Default buffer size for stream operations (128KB). + /// + public const int DefaultStreamBufferSize = 131072; + + /// + /// Chunk size in bytes for parallel range downloads (2MB). + /// + public const long DownloadChunkSizeBytes = 2 * 1024 * 1024; + + /// + /// Maximum number of concurrent connections for parallel downloads. + /// + public const int ParallelDownloadConcurrency = 8; + + /// + /// Minimum file size threshold in bytes to trigger parallel chunked downloading (4MB). + /// + public const long ParallelDownloadThresholdBytes = 4 * 1024 * 1024; + /// /// Delay before exit after applying update (5 seconds). /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs index be8cebf08..db6350c4e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs @@ -92,4 +92,18 @@ public void SortOption_Constants_ShouldBeDistinctAndNonEmpty() Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc); Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc); } + + /// + /// Tests that parallel download constants have valid positive values. + /// + [Fact] + public void ParallelDownload_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(131072, AppUpdateConstants.DefaultStreamBufferSize); + Assert.Equal(2 * 1024 * 1024, AppUpdateConstants.DownloadChunkSizeBytes); + Assert.Equal(8, AppUpdateConstants.ParallelDownloadConcurrency); + Assert.Equal(4 * 1024 * 1024, AppUpdateConstants.ParallelDownloadThresholdBytes); + Assert.True(AppUpdateConstants.ParallelDownloadConcurrency > 0); + Assert.True(AppUpdateConstants.DownloadChunkSizeBytes > 0); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs new file mode 100644 index 000000000..2c5db6ecf --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs @@ -0,0 +1,535 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Features.AppUpdate.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.AppUpdate.Services; + +/// +/// Unit tests for . +/// +public class FastHttpClientFileDownloaderTests : IDisposable +{ + private sealed class TestHttpMessageHandler(Func handlerFunc) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return Task.FromResult(handlerFunc(request)); + } + } + + private readonly Mock> _mockLogger = new(); + private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-downloader-tests-{Guid.NewGuid():N}"); + + /// + /// Initializes a new instance of the class. + /// + public FastHttpClientFileDownloaderTests() + { + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Disposes test resources and cleans up temporary directories. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore test directory cleanup failures + } + } + } + + /// + /// Tests that the downloader can be initialized with and without a logger. + /// + [Fact] + public void Constructor_ShouldInitializeSuccessfully() + { + var downloaderWithoutLogger = new FastHttpClientFileDownloader(); + var downloaderWithLogger = new FastHttpClientFileDownloader(_mockLogger.Object); + + Assert.NotNull(downloaderWithoutLogger); + Assert.NotNull(downloaderWithLogger); + } + + /// + /// Tests that DownloadFile throws ArgumentException when URL is invalid. + /// + /// The invalid URL string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidUrl_ShouldThrowArgumentExceptionAsync(string? invalidUrl) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + var targetFile = Path.Combine(_tempDirectory, "test.tmp"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile(invalidUrl!, targetFile, _ => { }, null, 30)); + } + + /// + /// Tests that DownloadFile throws ArgumentException when target file path is invalid. + /// + /// The invalid target file path string. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadFile_WithInvalidTargetFile_ShouldThrowArgumentExceptionAsync(string? invalidTargetFile) + { + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.zip", invalidTargetFile!, _ => { }, null, 30)); + } + + /// + /// Tests that parallel chunk downloading correctly assembles multi-chunk files and reports progress monotonically. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ParallelRange_ValidAssembly_ShouldDownloadAndVerifyContentAsync() + { + // 6 MB file (3 chunks of 2 MB) + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe request + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "parallel-output.bin"); + + await downloader.DownloadFile( + "https://github.com/community-outpost/GenHub/releases/download/v1.0.0/test.bin", + targetFile, + progressHistory.Enqueue, + null, + 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + + var progressList = progressHistory.ToList(); + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + } + + /// + /// Tests that small files below the parallel threshold use single-stream mode without chunking. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_SmallFileBelowThreshold_ShouldUseSingleStreamAsync() + { + var smallBytes = new byte[1024 * 1024]; // 1 MB + new Random(42).NextBytes(smallBytes); + + var chunkRequestsCount = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response indicates 1MB file + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([smallBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, smallBytes.Length) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + Interlocked.Increment(ref chunkRequestsCount); + } + + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(smallBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "small-file.bin"); + + await downloader.DownloadFile("https://example.com/small.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(smallBytes, downloadedBytes); + Assert.Equal(0, chunkRequestsCount); + } + + /// + /// Tests that when the server ignores range headers (returning 200 OK on probe), the downloader streams directly without error. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ServerIgnoresRange_ShouldStreamProbeResponseDirectlyAsync() + { + var fileBytes = new byte[1024 * 512]; // 512 KB + new Random(1337).NextBytes(fileBytes); + + var handler = new TestHttpMessageHandler(request => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileBytes), + RequestMessage = request, + }; + return response; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "ignored-range.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(fileBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response returns an invalid Content-Range header, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_InvalidContentRange_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(77).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is not null) + { + // Return mismatched Content-Range + var badResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + badResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(999, 1098, totalBytes) { Unit = "bytes" }; + return badResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-invalid-range.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that when a chunk response streams fewer bytes than requested, the downloader falls back to single-stream. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ShortChunkStream_ShouldFallbackToSingleStreamAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(99).NextBytes(sourceBytes); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + // Probe response + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + // Return short stream (100 bytes instead of expected chunk length) + var shortResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[100]), + RequestMessage = request, + }; + shortResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return shortResponse; + } + + // Fallback path sends full payload + var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + return fullResponse; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "fallback-short-chunk.bin"); + + await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30); + + Assert.True(File.Exists(targetFile)); + var downloadedBytes = await File.ReadAllBytesAsync(targetFile); + Assert.Equal(sourceBytes, downloadedBytes); + } + + /// + /// Tests that progress reporting is strictly monotonic (never moves backward) and throttled to at most 101 updates. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_ProgressReporting_ShouldBeStrictlyMonotonicAndThrottledAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; // 24 MB + var sourceBytes = new byte[totalBytes]; + + var progressHistory = new ConcurrentQueue(); + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([0]), + RequestMessage = request, + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + var length = (int)(to - from + 1); + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(new byte[length]), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "progress-test.bin"); + + await downloader.DownloadFile("https://example.com/file.bin", targetFile, progressHistory.Enqueue, null, 30); + + var progressList = progressHistory.ToList(); + + Assert.NotEmpty(progressList); + Assert.Equal(100, progressList.Last()); + + // Verify strictly monotonic ordering (each progress event >= previous) + for (var i = 1; i < progressList.Count; i++) + { + Assert.True(progressList[i] >= progressList[i - 1], $"Progress moved backward from {progressList[i - 1]} to {progressList[i]}"); + } + + // Verify throttling: no more than 101 progress updates (0 to 100) + Assert.True(progressList.Count <= 101, $"Progress was called {progressList.Count} times, exceeding maximum throttled limit of 101"); + } + + /// + /// Tests that cancellation tokens are properly observed and propagated. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenCancelled_ShouldThrowOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new TestHttpMessageHandler(request => new HttpResponseMessage(HttpStatusCode.OK)); + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "canceled.bin"); + + await Assert.ThrowsAnyAsync( + () => downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30, cts.Token)); + } + + /// + /// Tests that when redirected to a cross-origin storage host, the Authorization header is omitted from chunk requests. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFile_WhenRedirectedToCrossOriginCdn_ShouldStripAuthorizationHeaderOnChunksAsync() + { + var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2; + var sourceBytes = new byte[totalBytes]; + new Random(42).NextBytes(sourceBytes); + + var chunkAuthHeadersPresent = 0; + + var handler = new TestHttpMessageHandler(request => + { + var range = request.Headers.Range?.Ranges.FirstOrDefault(); + if (range is { From: 0, To: 0 }) + { + var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent([sourceBytes[0]]), + RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://cdn.blob.core.windows.net/artifacts/file.zip"), + }; + probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" }; + return probeResponse; + } + + if (range is { From: { } from, To: { } to }) + { + if (request.Headers.Contains("Authorization")) + { + Interlocked.Increment(ref chunkAuthHeadersPresent); + } + + var length = (int)(to - from + 1); + var chunkData = new byte[length]; + Array.Copy(sourceBytes, from, chunkData, 0, length); + + var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent) + { + Content = new ByteArrayContent(chunkData), + RequestMessage = request, + }; + chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" }; + return chunkResponse; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(sourceBytes), + RequestMessage = request, + }; + }); + + var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler); + var targetFile = Path.Combine(_tempDirectory, "cross-origin-test.bin"); + var headers = new Dictionary + { + { "Authorization", "Bearer test_pat_token" }, + { "User-Agent", "GenHub" }, + }; + + await downloader.DownloadFile( + "https://api.github.com/repos/community-outpost/GenHub/actions/artifacts/123/zip", + targetFile, + _ => { }, + headers, + 30); + + Assert.True(File.Exists(targetFile)); + Assert.Equal(sourceBytes, await File.ReadAllBytesAsync(targetFile)); + Assert.Equal(0, chunkAuthHeadersPresent); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs index 5719dac51..18d980766 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs @@ -3,6 +3,7 @@ using GenHub.Features.AppUpdate.Services; using Microsoft.Extensions.Logging; using Moq; +using Velopack.Sources; namespace GenHub.Tests.Core.Features.AppUpdate.Services; @@ -188,6 +189,28 @@ public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNullAsync( Assert.False(manager.HasArtifactUpdateAvailable); } + /// + /// Tests that VelopackUpdateManager accepts a custom IFileDownloader. + /// + [Fact] + public void Constructor_WithCustomFileDownloader_ShouldInitializeSuccessfully() + { + // Arrange + var customDownloader = new Mock().Object; + + // Act + var manager = new VelopackUpdateManager( + _mockLogger.Object, + _mockHttpClientFactory.Object, + _mockGitHubTokenStorage.Object, + _mockUserSettingsService.Object, + customDownloader); + + // Assert + Assert.NotNull(manager); + Assert.False(manager.IsUpdatePendingRestart); + } + /// /// Creates a new VelopackUpdateManager instance with mocked dependencies. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index b41d665af..0f9e6feb1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -164,7 +164,10 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_FailsWi stopwatch.Stop(); Assert.False(result.Success); - Assert.Contains("without starting", string.Join(", ", result.Errors)); + var errors = string.Join(", ", result.Errors); + Assert.True( + errors.Contains("without starting") || errors.Contains("start time could not be read"), + $"Expected exit failure message, but got: {errors}"); Assert.True( stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Expected a fast failure once the launcher exited, but it took {stopwatch.Elapsed}."); @@ -196,7 +199,9 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_Reports Assert.False(result.Success); var errors = string.Join(", ", result.Errors); - Assert.True(errors.Contains("without starting") || errors.Contains("did not start"), $"Expected start failure message, but got: {errors}"); + Assert.True( + errors.Contains("without starting") || errors.Contains("did not start") || errors.Contains("start time could not be read"), + $"Expected start failure message, but got: {errors}"); Assert.Contains(complaint, errors); } diff --git a/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs b/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs new file mode 100644 index 000000000..211accb18 --- /dev/null +++ b/GenHub/GenHub/Features/AppUpdate/Services/FastHttpClientFileDownloader.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using Microsoft.Extensions.Logging; +using Velopack.Sources; + +namespace GenHub.Features.AppUpdate.Services; + +/// +/// High-performance file downloader for Velopack and application updates. +/// Supports parallel range chunk downloading for large assets from GitHub Releases and CDN origins. +/// +public class FastHttpClientFileDownloader( + ILogger? logger = null, + HttpMessageHandler? httpMessageHandler = null) : HttpClientFileDownloader +{ + private static readonly SocketsHttpHandler SharedSocketsHandler = new() + { + MaxConnectionsPerServer = 32, + EnableMultipleHttp2Connections = true, + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60), + ConnectTimeout = TimeSpan.FromSeconds(30), + }; + + private sealed class MonotonicProgressReporter(Action? progressCallback, long totalBytes) + { + private readonly object _sync = new(); + private int _lastReportedPercent = -1; + private long _totalBytesDownloaded; + + public void ReportBytesRead(int bytesRead) + { + if (progressCallback is null || totalBytes <= 0) + { + return; + } + + var currentTotal = Interlocked.Add(ref _totalBytesDownloaded, bytesRead); + var currentPercent = (int)Math.Clamp((double)currentTotal / totalBytes * 100, 0, 99); + + if (currentPercent <= Volatile.Read(ref _lastReportedPercent)) + { + return; + } + + lock (_sync) + { + if (currentPercent > _lastReportedPercent) + { + _lastReportedPercent = currentPercent; + progressCallback(currentPercent); + } + } + } + + public void Complete() + { + if (progressCallback is null) + { + return; + } + + lock (_sync) + { + if (_lastReportedPercent < 100) + { + _lastReportedPercent = 100; + progressCallback(100); + } + } + } + } + + /// + public override async Task DownloadFile( + string url, + string targetFile, + Action progress, + IDictionary? headers, + double timeout, + CancellationToken cancelToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFile); + + var destinationDirectory = Path.GetDirectoryName(targetFile); + if (!string.IsNullOrEmpty(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + using var client = CreateHttpClient(headers, timeout); + + try + { + // Probe range support and resolve redirects without holding open full stream + using var probeRequest = new HttpRequestMessage(HttpMethod.Get, url); + probeRequest.Headers.Range = new RangeHeaderValue(0, 0); + + using var probeResponse = await client.SendAsync( + probeRequest, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + probeResponse.EnsureSuccessStatusCode(); + + var resolvedUri = probeResponse.RequestMessage?.RequestUri ?? new Uri(url); + var contentRange = probeResponse.Content.Headers.ContentRange; + + // Validate that probe returned 206 Partial Content with valid byte range (bytes 0-0/totalLength) + var hasValidProbeRange = probeResponse.StatusCode == HttpStatusCode.PartialContent && + contentRange is not null && + string.Equals(contentRange.Unit, "bytes", StringComparison.OrdinalIgnoreCase) && + contentRange.From == 0 && + contentRange.To == 0 && + contentRange.Length is { } probeTotalLength && + probeTotalLength >= AppUpdateConstants.ParallelDownloadThresholdBytes; + + if (hasValidProbeRange) + { + var totalLength = contentRange!.Length!.Value; + probeResponse.Dispose(); + + logger?.LogInformation( + "Downloading {Url} via parallel chunk mode ({Concurrency} connections, Size: {Size:N0} bytes)", + url, + AppUpdateConstants.ParallelDownloadConcurrency, + totalLength); + + // If redirected to a third-party CDN/storage host (e.g. Azure Blob/S3), strip Authorization header to avoid 400 Bad Request on presigned URLs + HttpClient chunkClient = client; + HttpClient? cdnClient = null; + var originUri = new Uri(url); + if (!string.Equals(resolvedUri.Host, originUri.Host, StringComparison.OrdinalIgnoreCase) && headers?.ContainsKey("Authorization") == true) + { + var cdnHeaders = headers.Where(h => !string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(h => h.Key, h => h.Value); + cdnClient = CreateHttpClient(cdnHeaders, timeout); + chunkClient = cdnClient; + } + + try + { + await DownloadParallelAsync( + chunkClient, + resolvedUri, + targetFile, + totalLength, + progress, + cancelToken).ConfigureAwait(false); + } + finally + { + cdnClient?.Dispose(); + } + + return; + } + + // If probe returned 200 OK (server ignored Range header), stream the probe response directly + if (probeResponse.StatusCode == HttpStatusCode.OK) + { + var totalBytes = probeResponse.Content.Headers.ContentLength ?? -1L; + await DownloadSingleStreamAsync(probeResponse, targetFile, totalBytes, progress, cancelToken).ConfigureAwait(false); + return; + } + + // Fallback to single-stream GET (e.g. for files below parallel threshold) + using var fullResponse = await client.GetAsync( + url, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + fullResponse.EnsureSuccessStatusCode(); + var fullBytes = fullResponse.Content.Headers.ContentLength ?? -1L; + await DownloadSingleStreamAsync(fullResponse, targetFile, fullBytes, progress, cancelToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + ex, + "Parallel download encountered an issue for {Url}. Falling back to default downloader", + url); + + await base.DownloadFile(url, targetFile, progress, headers, timeout, cancelToken).ConfigureAwait(false); + } + } + + /// + protected override HttpClient CreateHttpClient(IDictionary? headers, double timeout) + { + var handler = httpMessageHandler ?? SharedSocketsHandler; + var client = new HttpClient(handler, disposeHandler: false); + if (timeout > 0) + { + client.Timeout = TimeSpan.FromSeconds(timeout); + } + + if (headers != null) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return client; + } + + private static async Task DownloadSingleStreamAsync( + HttpResponseMessage response, + string targetFile, + long totalBytes, + Action? progress, + CancellationToken cancelToken) + { + var progressReporter = new MonotonicProgressReporter(progress, totalBytes); + + await using var contentStream = await response.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); + await using var fileStream = new FileStream( + targetFile, + FileMode.Create, + FileAccess.Write, + FileShare.None, + AppUpdateConstants.DefaultStreamBufferSize, + useAsync: true); + + var buffer = new byte[AppUpdateConstants.DefaultStreamBufferSize]; + int bytesRead = 0; + + while ((bytesRead = await contentStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancelToken).ConfigureAwait(false)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancelToken).ConfigureAwait(false); + progressReporter.ReportBytesRead(bytesRead); + } + + progressReporter.Complete(); + } + + private static async Task DownloadParallelAsync( + HttpClient client, + Uri uri, + string targetFile, + long totalBytes, + Action? progress, + CancellationToken cancelToken) + { + // Pre-allocate the full file on disk and open safe handle for lock-free parallel writes + using var fileHandle = File.OpenHandle( + targetFile, + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite, + FileOptions.Asynchronous); + + RandomAccess.SetLength(fileHandle, totalBytes); + + var chunkSize = AppUpdateConstants.DownloadChunkSizeBytes; + var chunkCount = (int)Math.Ceiling((double)totalBytes / chunkSize); + var progressReporter = new MonotonicProgressReporter(progress, totalBytes); + + using var semaphore = new SemaphoreSlim(AppUpdateConstants.ParallelDownloadConcurrency); + + var tasks = Enumerable.Range(0, chunkCount).Select(async chunkIndex => + { + await semaphore.WaitAsync(cancelToken).ConfigureAwait(false); + try + { + var start = chunkIndex * chunkSize; + var end = Math.Min(start + chunkSize - 1, totalBytes - 1); + var expectedChunkBytes = end - start + 1; + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Range = new RangeHeaderValue(start, end); + + using var chunkResponse = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancelToken).ConfigureAwait(false); + + if (chunkResponse.StatusCode != HttpStatusCode.PartialContent) + { + throw new InvalidOperationException( + $"Origin server returned status code {chunkResponse.StatusCode} instead of 206 Partial Content for range {start}-{end}."); + } + + var chunkRange = chunkResponse.Content.Headers.ContentRange; + if (chunkRange is null || + !string.Equals(chunkRange.Unit, "bytes", StringComparison.OrdinalIgnoreCase) || + chunkRange.From != start || + chunkRange.To != end || + (chunkRange.Length.HasValue && chunkRange.Length.Value != totalBytes)) + { + throw new InvalidOperationException( + $"Origin server returned invalid Content-Range ({chunkRange}) for requested range {start}-{end} with total size {totalBytes}."); + } + + await using var chunkStream = await chunkResponse.Content.ReadAsStreamAsync(cancelToken).ConfigureAwait(false); + + var buffer = new byte[AppUpdateConstants.DefaultStreamBufferSize]; + var chunkBytesRead = 0L; + int bytesRead = 0; + + while ((bytesRead = await chunkStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancelToken).ConfigureAwait(false)) > 0) + { + await RandomAccess.WriteAsync( + fileHandle, + buffer.AsMemory(0, bytesRead), + start + chunkBytesRead, + cancelToken).ConfigureAwait(false); + + chunkBytesRead += bytesRead; + progressReporter.ReportBytesRead(bytesRead); + } + + if (chunkBytesRead != expectedChunkBytes) + { + throw new InvalidOperationException( + $"Chunk range {start}-{end} received {chunkBytesRead} bytes, expected {expectedChunkBytes}."); + } + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks).ConfigureAwait(false); + progressReporter.Complete(); + } +} diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 3b0388c1f..5f1d9f05d 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -40,6 +40,7 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable private readonly IHttpClientFactory _httpClientFactory; private readonly IGitHubTokenStorage? _gitHubTokenStorage; private readonly IUserSettingsService? _userSettingsService; + private readonly IFileDownloader _fileDownloader; private readonly UpdateManager? _updateManager; private readonly GithubSource _githubSource; @@ -108,19 +109,22 @@ public string? SubscribedBranch /// The HTTP client factory for creating HttpClient instances. /// The GitHub token storage (optional). /// The user settings service (optional). + /// The high-performance file downloader (optional). public VelopackUpdateManager( ILogger logger, IHttpClientFactory httpClientFactory, IGitHubTokenStorage? gitHubTokenStorage = null, - IUserSettingsService? userSettingsService = null) + IUserSettingsService? userSettingsService = null, + IFileDownloader? fileDownloader = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _gitHubTokenStorage = gitHubTokenStorage; _userSettingsService = userSettingsService; + _fileDownloader = fileDownloader ?? new FastHttpClientFileDownloader(); - // Always initialize GithubSource for update checking - _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true); + // Always initialize GithubSource for update checking with high-performance downloader + _githubSource = new GithubSource(AppConstants.GitHubRepositoryUrl, string.Empty, true, _fileDownloader); try { @@ -706,7 +710,6 @@ public async Task InstallArtifactAsync( throw new InvalidOperationException("Failed to load GitHub PAT"); } - using var client = CreateConfiguredHttpClientWithToken(token); var owner = AppConstants.GitHubRepositoryOwner; var repo = AppConstants.GitHubRepositoryName; var artifactId = artifactInfo.ArtifactId; @@ -721,33 +724,36 @@ public async Task InstallArtifactAsync( var zipPath = Path.Combine(tempDir, "artifact.zip"); - // Download artifact - var downloadProgress = new Progress(p => + var headers = new Dictionary { - // Scale 0-100% download to 0-30% total progress - var totalPercent = (int)(p.PercentComplete * 0.3); + { "User-Agent", AppConstants.AppName }, + { "Accept", ApiConstants.GitHubApiHeaderAccept }, + }; - // Format decimal size if possible - string sizeInfo = string.Empty; - if (p.TotalBytes > 0) - { - double currentMb = p.BytesDownloaded / 1024.0 / 1024.0; - double totalMb = p.TotalBytes / 1024.0 / 1024.0; - double speedMb = p.BytesPerSecond / 1024.0 / 1024.0; - sizeInfo = $" ({currentMb:F1}/{totalMb:F1} MB, {speedMb:F1} MB/s)"; - } + UseSecureStringAsPlainText(token, plainText => + { + headers["Authorization"] = $"Bearer {plainText}"; + }); + + var downloadProgress = new Action(percent => + { + // Scale 0-100% download to 0-30% total progress + var totalPercent = (int)(percent * 0.3); progress?.Report(new UpdateProgress { - Status = $"Downloading artifact for {label}{commitInfo}... {p.PercentComplete}%{sizeInfo}", + Status = $"Downloading artifact for {label}{commitInfo}... {percent}%", PercentComplete = totalPercent, - BytesDownloaded = p.BytesDownloaded, - TotalBytes = p.TotalBytes, - BytesPerSecond = p.BytesPerSecond, }); }); - await DownloadFileWithProgressAsync(client, downloadUrl, zipPath, downloadProgress, cancellationToken); + await _fileDownloader.DownloadFile( + downloadUrl, + zipPath, + downloadProgress, + headers, + timeout: 300, + cancelToken: cancellationToken); progress?.Report(new UpdateProgress { Status = "Extracting artifact...", PercentComplete = 30 }); @@ -809,7 +815,7 @@ public async Task InstallArtifactAsync( progress?.Report(new UpdateProgress { Status = "Downloading update...", PercentComplete = 70 }); // Point Velopack to localhost - var source = new SimpleWebSource($"http://localhost:{port}/{server.SecretToken}/"); + var source = new SimpleWebSource($"http://localhost:{port}/{server.SecretToken}/", _fileDownloader); var localUpdateManager = new UpdateManager(source); try @@ -1099,80 +1105,6 @@ private static int FindAvailablePort() return port; } - /// - /// Downloads a file with progress reporting. - /// - private static async Task DownloadFileWithProgressAsync( - HttpClient client, - string requestUrl, - string destinationPath, - IProgress? progress, - CancellationToken cancellationToken) - { - using var response = await client.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1L; - - // Create temp directory if it doesn't exist - var directory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(directory)) - { - Directory.CreateDirectory(directory); - } - - using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); - - var totalRead = 0L; - var buffer = new byte[8192]; - var isMoreToRead = true; - - var stopwatch = Stopwatch.StartNew(); - var lastReportTime = stopwatch.ElapsedMilliseconds; - - while (isMoreToRead) - { - var read = await contentStream.ReadAsync(buffer, cancellationToken); - if (read == 0) - { - isMoreToRead = false; - } - else - { - await fileStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - - totalRead += read; - - var currentTime = stopwatch.ElapsedMilliseconds; - - // Report every 500ms - if (currentTime - lastReportTime >= 500 || !isMoreToRead) - { - if (progress != null) - { - var elapsedSeconds = stopwatch.Elapsed.TotalSeconds; - var bytesPerSecond = elapsedSeconds > 0 ? (long)(totalRead / elapsedSeconds) : 0L; - var percent = totalBytes > 0 ? (int)((double)totalRead / totalBytes * 100) : 0; - - progress.Report(new UpdateProgress - { - PercentComplete = percent, - BytesDownloaded = totalRead, - TotalBytes = totalBytes, - BytesPerSecond = bytesPerSecond, - Status = "Downloading...", - }); - } - - lastReportTime = currentTime; - } - } - } - - stopwatch.Stop(); - } - /// /// Gets or creates an HttpClient instance with proper configuration. /// diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/AppUpdateModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/AppUpdateModule.cs index cda692368..2029ee5a2 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/AppUpdateModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/AppUpdateModule.cs @@ -2,6 +2,7 @@ using GenHub.Features.AppUpdate.Services; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Velopack.Sources; namespace GenHub.Infrastructure.DependencyInjection; @@ -20,6 +21,10 @@ public static IServiceCollection AddAppUpdateModule(this IServiceCollection serv // Register HTTP client factory for proper HttpClient lifecycle management services.AddHttpClient(); + // Register high-performance file downloader for Velopack + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + // Register Velopack update manager (only update system needed) services.AddSingleton(); From e7fab07d11eec36e22c51fdab675ba3effa8a7d4 Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:55:56 +0200 Subject: [PATCH 15/20] fix(update): Fix PR number being ignored while checking for updates (#405) --- .../Helpers/AppUpdateVersionHelper.cs | 51 +++++++++++++++- .../UpdateNotificationViewModelTests.cs | 22 +++++++ .../Helpers/AppUpdateVersionHelperTests.cs | 60 ++++++++++++++++++- .../ViewModels/UpdateNotificationViewModel.cs | 13 +++- 4 files changed, 142 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs index b44ff9c53..8e2288065 100644 --- a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs @@ -9,6 +9,38 @@ namespace GenHub.Core.Helpers; /// public static partial class AppUpdateVersionHelper { + /// + /// Extracts the channel key (e.g., "pr242", "main", "development", "release", "ci") from a version string. + /// + /// The version string to extract the channel from. + /// The normalized channel key, or null if the version is null or empty. + public static string? ExtractChannelKey(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return null; + } + + var clean = version.Split('+')[0].Trim(); + var dashIndex = clean.IndexOf('-'); + if (dashIndex >= 0 && dashIndex < clean.Length - 1) + { + var suffix = clean[(dashIndex + 1)..].Trim(); + if (!string.IsNullOrEmpty(suffix)) + { + var ciMatch = CiMarkerRegex().Match(clean); + if (ciMatch.Success && suffix.StartsWith("ci.", StringComparison.OrdinalIgnoreCase)) + { + return "ci"; + } + + return suffix.ToLowerInvariant(); + } + } + + return "release"; + } + /// /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641). /// Returns 0 for plain semantic versions without CI run markers. @@ -39,11 +71,13 @@ public static int ExtractRunNumber(string? version) /// /// Checks whether an available artifact version is newer than the currently installed version. + /// Rejects cross-channel sequential comparisons when the current installation belongs to a specific channel. /// /// The new artifact version string. /// The current version string. + /// Whether to allow comparing versions from different channels. /// True if newVersion is newer than currentVersion; otherwise false. - public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion) + public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion, bool allowCrossChannel = false) { if (string.IsNullOrWhiteSpace(newVersion)) { @@ -61,6 +95,21 @@ public static bool IsArtifactVersionNewer(string? newVersion, string? currentVer var newRun = ExtractRunNumber(newVersionBase); var currentRun = ExtractRunNumber(currentVersionBase); + if (!allowCrossChannel) + { + var newChannel = ExtractChannelKey(newVersionBase); + var currentChannel = ExtractChannelKey(currentVersionBase); + + // If the currently installed build belongs to a specific channel (e.g. "pr242", "main", "development"), + // reject updates from any different channel (e.g. "pr265"). + if (!string.IsNullOrEmpty(currentChannel) && + !string.Equals(currentChannel, "release", StringComparison.OrdinalIgnoreCase) && + !string.Equals(newChannel, currentChannel, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + if (newRun > 0 && currentRun > 0) { return newRun > currentRun; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs index b4ea11d25..5ad314dec 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -568,4 +568,26 @@ public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain() Assert.False(string.IsNullOrEmpty(vm.StatusMessage)); Assert.Null(mockVelopack.Object.SubscribedPrNumber); } + + /// + /// Verifies that InitializeAsync seeds SubscribedPr immediately from user settings. + /// + [Fact] + public void Constructor_WhenPrSubscribedInSettings_SeedsSubscribedPr() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 242 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(242, mockVelopack.Object.SubscribedPrNumber); + Assert.NotNull(vm.SubscribedPr); + Assert.Equal(242, vm.SubscribedPr.Number); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs index 84ab32157..68107f185 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs @@ -1,4 +1,5 @@ using GenHub.Core.Helpers; +using Xunit; namespace GenHub.Tests.Core.Helpers; @@ -7,6 +8,29 @@ namespace GenHub.Tests.Core.Helpers; /// public class AppUpdateVersionHelperTests { + /// + /// Tests that ExtractChannelKey extracts expected channel identifiers. + /// + /// The version string to extract the channel from. + /// The expected channel key. + [Theory] + [InlineData("0.0.1520-pr242", "pr242")] + [InlineData("0.0.1525-pr265", "pr265")] + [InlineData("0.0.1287-main", "main")] + [InlineData("0.0.1287-development", "development")] + [InlineData("0.0.0-ci.500", "ci")] + [InlineData("0.0.1300-fix-ci.9", "fix-ci.9")] + [InlineData("1.0.42", "release")] + [InlineData("0.0.1287", "release")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData(null, null)] + public void ExtractChannelKey_WithVariousFormats_ShouldReturnExpectedChannel(string? version, string? expectedChannel) + { + var result = AppUpdateVersionHelper.ExtractChannelKey(version); + Assert.Equal(expectedChannel, result); + } + /// /// Tests that ExtractRunNumber extracts expected run numbers. /// @@ -33,7 +57,7 @@ public void ExtractRunNumber_WithVariousFormats_ShouldReturnExpectedNumber(strin } /// - /// Tests that IsArtifactVersionNewer returns true when new run is greater. + /// Tests that IsArtifactVersionNewer returns true when new run is greater within the same channel. /// [Fact] public void IsArtifactVersionNewer_WhenNewerRun_ShouldReturnTrue() @@ -42,6 +66,40 @@ public void IsArtifactVersionNewer_WhenNewerRun_ShouldReturnTrue() Assert.True(result); } + /// + /// Tests that IsArtifactVersionNewer returns false when comparing builds from different PR channels. + /// + [Fact] + public void IsArtifactVersionNewer_WhenDifferentPrChannels_ShouldReturnFalse() + { + // PR #265 at run 1525 vs PR #242 at run 1520 must NOT be considered an upgrade + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-pr265", "0.0.1520-pr242"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when comparing PR builds with branch builds. + /// + [Fact] + public void IsArtifactVersionNewer_WhenDifferentBranchChannels_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-main", "0.0.1520-development"); + Assert.False(result); + + var prVsBranch = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-main", "0.0.1520-pr242"); + Assert.False(prVsBranch); + } + + /// + /// Tests that IsArtifactVersionNewer allows cross-channel comparison when explicitly requested. + /// + [Fact] + public void IsArtifactVersionNewer_WhenCrossChannelExplicitlyAllowed_ShouldReturnTrueForHigherRun() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1525-pr265", "0.0.1520-pr242", allowCrossChannel: true); + Assert.True(result); + } + /// /// Tests that IsArtifactVersionNewer returns false when same run. /// diff --git a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs index d6d9461c5..a50bd3cae 100644 --- a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs +++ b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs @@ -446,8 +446,17 @@ private async Task InitializeAsync() var settings = _userSettingsService.Get(); if (settings.SubscribedPrNumber.HasValue) { - _velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; - _logger.LogInformation("Loaded subscribed PR #{PrNumber} from settings", settings.SubscribedPrNumber); + var prNumber = settings.SubscribedPrNumber.Value; + _velopackUpdateManager.SubscribedPrNumber = prNumber; + SubscribedPr = new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; + _logger.LogInformation("Loaded subscribed PR #{PrNumber} from settings", prNumber); } if (!string.IsNullOrEmpty(settings.SubscribedBranch)) From aaaff0af398288141c34e1a72dde159383de69e6 Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:38:59 +0200 Subject: [PATCH 16/20] fix(launcher): resolve alpha 4 setup wizard UX and linux flatpak steam detection (#406) --- .github/workflows/ci.yml | 18 +- AGENTS.md | 3 + .../Constants/ManifestConstants.cs | 10 + .../GenHub.Core/Constants/ProfileConstants.cs | 5 + GenHub/GenHub.Core/Helpers/PathHelper.cs | 18 +- .../GameInstallations/SteamInstallation.cs | 227 ++++++++++---- .../Common/Services/LegacyRootUpgradeTests.cs | 2 +- .../GameProfileLauncherViewModelTests.cs | 90 ++++++ .../Wizard/SetupWizardViewModelTests.cs | 122 ++++++++ .../UserDataTrackerServiceSafetyTests.cs | 20 +- .../Workspace/FileOperationsServiceTests.cs | 10 +- .../SteamInstallationTests.cs | 73 +++++ .../Common/Services/UserSettingsService.cs | 6 +- .../Services/DependencyResolver.cs | 66 ++++ .../Services/SetupWizardService.cs | 38 ++- .../GameProfileLauncherViewModel.cs | 62 ++-- .../GameProfileSettingsViewModel.Commands.cs | 2 - ...ProfileSettingsViewModel.Initialization.cs | 2 +- .../GameProfileSettingsViewModel.cs | 232 +++++++++----- .../Wizard/SetupWizardItemViewModel.cs | 7 +- .../ViewModels/Wizard/SetupWizardViewModel.cs | 11 +- .../Views/GameProfileLauncherView.axaml | 48 ++- .../Views/Wizard/SetupWizardView.axaml | 205 +++++++----- .../GameSettings/GameSettingsService.cs | 294 +++++++++--------- 24 files changed, 1138 insertions(+), 433 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f220afae0..2951d1ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,16 +124,18 @@ jobs: id: buildinfo shell: pwsh run: | - $shortHash = "${{ github.sha }}".Substring(0, 7) $prNumber = "${{ github.event.pull_request.number }}" $runNumber = "${{ github.run_number }}" + $headSha = "${{ github.event.pull_request.head.sha }}" # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) # Using 0.0.X format to indicate alpha/pre-release status - if ($prNumber) { + if ($prNumber -and $headSha) { + $shortHash = $headSha.Substring(0, 7) $version = "0.0.$runNumber-pr$prNumber" $channel = "PR" } else { + $shortHash = "${{ github.sha }}".Substring(0, 7) $version = "0.0.$runNumber" $channel = "CI" } @@ -277,16 +279,18 @@ jobs: - name: Extract Build Info id: buildinfo run: | - SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) PR_NUMBER="${{ github.event.pull_request.number }}" RUN_NUMBER="${{ github.run_number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" # Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH) # Using 0.0.X format to indicate alpha/pre-release status - if [ -n "$PR_NUMBER" ]; then + if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then + SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}" CHANNEL="PR" else + SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}" CHANNEL="CI" fi @@ -402,14 +406,16 @@ jobs: - name: Extract Build Info id: buildinfo run: | - SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) PR_NUMBER="${{ github.event.pull_request.number }}" RUN_NUMBER="${{ github.run_number }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" - if [ -n "$PR_NUMBER" ]; then + if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then + SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}" CHANNEL="PR" else + SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7) VERSION="0.0.${RUN_NUMBER}" CHANNEL="CI" fi diff --git a/AGENTS.md b/AGENTS.md index 8c23bb9b8..3b56759c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,9 @@ This repository uses **GitNexus** to maintain an AST-parsed structural knowledge - **No `this.`:** Never qualify instance members with `this.`. - **Namespaces:** Always use file-scoped or top-level namespace declarations. Alphabetize all `using` directives at the very top of the file. Never use inline namespaces. - **Comment Casing:** Use standard sentence casing in comments. Never capitalize arbitrary words mid-comment. +- **Variables & Declarations:** Always initialize local variables upon declaration. Never leave uninitialized variables (`CS-W1022`) or unused variables (`CS-W1100`). Use discards (`_`) for unused `using` scopes or out parameters. +- **Switch Statements:** Always include a `default` case (`CS-W1009`) in `switch` statements and expressions. +- **Exception Handling:** Never catch generic `Exception` (`CS-R1008`) unless explicitly required for top-level process/worker boundaries. Always catch specific exception types (`IOException`, `UnauthorizedAccessException`, etc.) or re-throw. - **Formatting:** 4 spaces indentation, Allman bracing style (opening brace on its own line), nullable reference types enabled. - **Member Ordering (StyleCop):** 1. Nested types diff --git a/GenHub/GenHub.Core/Constants/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs index bdfc551dc..d58959ce7 100644 --- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs +++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs @@ -100,6 +100,16 @@ public static class ManifestConstants /// public const string DefaultContentDependencyId = "1.0.genhub.content.defaultdependency"; + /// + /// Wildcard token representing any publisher in dependency declarations. + /// + public const string AnyPublisherToken = "any"; + + /// + /// Separator used to append variant identifiers to content names. + /// + public const string VariantSeparator = "-"; + /// /// Version string for Generals game installation manifests. /// This represents the executable version 1.08. diff --git a/GenHub/GenHub.Core/Constants/ProfileConstants.cs b/GenHub/GenHub.Core/Constants/ProfileConstants.cs index 9900e4e39..f827a6fbe 100644 --- a/GenHub/GenHub.Core/Constants/ProfileConstants.cs +++ b/GenHub/GenHub.Core/Constants/ProfileConstants.cs @@ -5,6 +5,11 @@ namespace GenHub.Core.Constants; /// public static class ProfileConstants { + /// + /// The default profile name used for new profiles. + /// + public const string DefaultProfileName = "New Profile"; + /// /// The workspace ID used for tool profiles. /// diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index 288f86fe2..b2bee2bfe 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -43,7 +43,23 @@ public static bool AreSamePath(string first, string second) Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), PathComparison); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException) + catch (IOException) + { + return string.Equals(first, second, PathComparison); + } + catch (UnauthorizedAccessException) + { + return string.Equals(first, second, PathComparison); + } + catch (SecurityException) + { + return string.Equals(first, second, PathComparison); + } + catch (NotSupportedException) + { + return string.Equals(first, second, PathComparison); + } + catch (ArgumentException) { return string.Equals(first, second, PathComparison); } diff --git a/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs b/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs index 06834b8db..6bd9d2385 100644 --- a/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs +++ b/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs @@ -165,78 +165,127 @@ public void Fetch() } } - /// - /// Gets Steam library paths on Linux. - /// - /// List of Steam library paths. - private List GetSteamLibraryPaths() + private static IReadOnlyList GetCandidateHomeDirectories() { - var libraryPaths = new List(); + var homeDirs = new HashSet(StringComparer.Ordinal); + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var envHome = Environment.GetEnvironmentVariable("HOME"); - try + AddHomeVariants(homeDirectory, homeDirs); + AddHomeVariants(envHome, homeDirs); + + return homeDirs.ToList(); + } + + private static void AddHomeVariants(string? path, HashSet homeDirs) + { + if (string.IsNullOrEmpty(path)) { - var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var steamConfigPaths = new Dictionary - { - { - ".steam/steam/steamapps/libraryfolders.vdf", - LinuxInstallationType.Binary - }, - { - ".local/share/Steam/steamapps/libraryfolders.vdf", - LinuxInstallationType.Binary - }, - { - ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/libraryfolders.vdf", - LinuxInstallationType.Flatpack - }, - { - "snap/steam/common/.local/share/Steam/steamapps/libraryfolders.vdf", - LinuxInstallationType.Snap - }, - { - "/usr/share/steam/steamapps/libraryfolders.vdf", - LinuxInstallationType.Unknown - }, - }; + return; + } - string? configFile = null; - foreach (KeyValuePair entry in steamConfigPaths) + homeDirs.Add(path); + if (path.StartsWith("/home/", StringComparison.Ordinal)) + { + homeDirs.Add("/var" + path); + } + else if (path.StartsWith("/var/home/", StringComparison.Ordinal)) + { + homeDirs.Add(path.Substring(4)); + } + } + + private static IReadOnlyList<(string ConfigFile, LinuxInstallationType Type)> GetSteamConfigFiles(IEnumerable homeDirs) + { + var steamConfigRelativePaths = new (string Path, LinuxInstallationType Type)[] + { + (".steam/steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary), + (".steam/root/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary), + (".local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary), + (".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack), + (".var/app/com.valvesoftware.Steam/data/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack), + (".var/app/com.valvesoftware.Steam/.steam/steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack), + (".var/app/com.valvesoftware.Steam/.steam/root/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack), + ("snap/steam/common/.local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Snap), + }; + + var configFiles = new List<(string ConfigFile, LinuxInstallationType Type)>(); + foreach (var home in homeDirs) + { + foreach (var (relPath, type) in steamConfigRelativePaths) { - if (File.Exists(Path.Combine(homeDirectory, entry.Key))) + var fullPath = Path.Combine(home, relPath); + if (File.Exists(fullPath)) { - configFile = Path.Combine(homeDirectory, entry.Key); - PackageInstallationType = entry.Value; - break; + configFiles.Add((fullPath, type)); } } + } - if (configFile == null) + const string systemConfigFile = "/usr/share/steam/steamapps/libraryfolders.vdf"; + if (File.Exists(systemConfigFile)) + { + configFiles.Add((systemConfigFile, LinuxInstallationType.Unknown)); + } + + return configFiles; + } + + private static void ResolveFlatpakFallbackPaths( + string steamPath, + IReadOnlyList homeDirs, + HashSet libraryPaths) + { + foreach (var home in homeDirs) + { + var flatpakLocal = Path.Combine(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common"); + if (Directory.Exists(flatpakLocal)) { - logger?.LogDebug("Steam library configuration file not found"); - return libraryPaths; + libraryPaths.Add(flatpakLocal); } - logger?.LogDebug("Reading Steam library configuration from: {ConfigFile}", configFile); + var flatpakData = Path.Combine(home, ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common"); + if (Directory.Exists(flatpakData)) + { + libraryPaths.Add(flatpakData); + } - var lines = File.ReadAllLines(configFile); - foreach (var line in lines) + // Map sandboxed home path to host Flatpak sandbox storage + if (steamPath.StartsWith(home, StringComparison.Ordinal)) { - if (!line.Contains("\"path\"")) - continue; + var relativePart = steamPath.Substring(home.Length).TrimStart('/'); + var flatpakMapped = Path.Combine(home, ".var/app/com.valvesoftware.Steam", relativePart, "steamapps", "common"); + if (Directory.Exists(flatpakMapped)) + { + libraryPaths.Add(flatpakMapped); + } + } + } + } - var parts = line.Split('"'); - if (parts.Length < 4) - continue; + /// + /// Gets Steam library paths on Linux. + /// + /// List of Steam library paths. + private List GetSteamLibraryPaths() + { + var libraryPaths = new HashSet(StringComparer.Ordinal); - var steamPath = parts[3].Trim(); - var commonPath = Path.Combine(steamPath, "steamapps", "common"); + try + { + var homeDirs = GetCandidateHomeDirectories(); + var configFiles = GetSteamConfigFiles(homeDirs); + CollectStandardLibraryPaths(homeDirs, libraryPaths); - if (Directory.Exists(commonPath)) - { - libraryPaths.Add(commonPath); - logger?.LogDebug("Found Steam library: {LibraryPath}", commonPath); - } + if (configFiles.Count == 0 && libraryPaths.Count == 0) + { + logger?.LogDebug("Steam library configuration file not found"); + return libraryPaths.ToList(); + } + + foreach (var (configFile, pkgType) in configFiles) + { + ParseSteamConfigFile(configFile, pkgType, homeDirs, libraryPaths); } } catch (Exception ex) @@ -244,6 +293,72 @@ private List GetSteamLibraryPaths() logger?.LogWarning(ex, "Failed to read Steam library paths"); } - return libraryPaths; + return libraryPaths.ToList(); + } + + private void CollectStandardLibraryPaths(IEnumerable homeDirs, HashSet libraryPaths) + { + var standardLibraryRelativePaths = new[] + { + ".local/share/Steam/steamapps/common", + ".steam/steam/steamapps/common", + ".steam/root/steamapps/common", + ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common", + ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common", + ".var/app/com.valvesoftware.Steam/.steam/steam/steamapps/common", + ".var/app/com.valvesoftware.Steam/.steam/root/steamapps/common", + "snap/steam/common/.local/share/Steam/steamapps/common", + }; + + foreach (var home in homeDirs) + { + foreach (var relLib in standardLibraryRelativePaths) + { + var fullLib = Path.Combine(home, relLib); + if (Directory.Exists(fullLib)) + { + libraryPaths.Add(fullLib); + logger?.LogDebug("Found Steam library via standard path: {LibraryPath}", fullLib); + } + } + } + } + + private void ParseSteamConfigFile( + string configFile, + LinuxInstallationType pkgType, + IReadOnlyList homeDirs, + HashSet libraryPaths) + { + PackageInstallationType = pkgType; + logger?.LogDebug("Reading Steam library configuration from: {ConfigFile}", configFile); + + var lines = File.ReadAllLines(configFile); + foreach (var line in lines) + { + if (!line.Contains("\"path\"")) + { + continue; + } + + var parts = line.Split('"'); + if (parts.Length < 4) + { + continue; + } + + var steamPath = parts[3].Trim(); + var commonPath = Path.Combine(steamPath, "steamapps", "common"); + + if (Directory.Exists(commonPath)) + { + libraryPaths.Add(commonPath); + logger?.LogDebug("Found Steam library: {LibraryPath}", commonPath); + } + else + { + ResolveFlatpakFallbackPaths(steamPath, homeDirs, libraryPaths); + } + } } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs index 9553e125f..887d63802 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs @@ -283,7 +283,7 @@ public async Task Save_WithUnreadableSettingsFile_RefusesToOverwriteAsync() """; File.WriteAllText(settingsPath, existingJson); - UserSettingsService service; + UserSettingsService service = null!; using (File.Open(settingsPath, System.IO.FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { service = CreateSettingsService(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs index 92737c49d..836d0ba16 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs @@ -18,6 +18,7 @@ using GenHub.Features.Content.Services.Publishers; using GenHub.Features.GameProfiles.Services; using GenHub.Features.GameProfiles.ViewModels; +using GenHub.Features.GameProfiles.ViewModels.Wizard; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -311,6 +312,95 @@ public void GenerateUniqueProfileName_CreatesUniqueName() Assert.Equal($"Test Profile {string.Format(ProfileConstants.CopyNameNumberedFormat, 3)}", uniqueName); } + /// + /// Verifies that ScanForGamesCommand creates zero profiles when the wizard is skipped/cancelled. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task ScanForGamesCommand_WhenWizardCancelled_CreatesZeroProfilesAsync() + { + var installationService = new Mock(); + var installation = new GameInstallation(Path.Combine("C:", "Steam", "Games"), GameInstallationType.Steam, new Mock>().Object); + installation.PopulateGameClients([ + new GameClient + { + Id = "cp-client", + Name = "Community Patch", + PublisherType = CommunityOutpostConstants.PublisherType, + GameType = GameType.ZeroHour, + }, + ]); + var installations = new List { installation }; + + installationService.Setup(x => x.GetAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess(installations)); + + var shortcutService = new Mock(); + var notificationService = new Mock(); + var publisherOrchestrator = new Mock(); + var profileManager = new Mock(); + var editorFacade = new Mock(); + + var setupWizardService = new Mock(); + setupWizardService.Setup(x => x.RunSetupWizardAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new SetupWizardResult + { + Confirmed = false, + CommunityPatchAction = GameClientConstants.WizardActionTypes.Install, + }); + + var vm = new GameProfileLauncherViewModel( + installationService.Object, + profileManager.Object, + null!, + null!, + editorFacade.Object, + null!, + null!, + shortcutService.Object, + publisherOrchestrator.Object, + new Mock().Object, + CreateProfileResourceService(), + new Mock().Object, + notificationService.Object, + setupWizardService.Object, + new Mock().Object, + NullLogger.Instance); + + await vm.ScanForGamesCommand.ExecuteAsync(null); + + Assert.Equal("Scan complete. Found 1 installations, created 0 profiles", vm.StatusMessage); + publisherOrchestrator.Verify( + x => x.CreateProfilesForPublisherClientAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + profileManager.Verify( + x => x.CreateProfileAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that SetupWizardItemViewModel strips leading 'v' or 'V' prefix. + /// + /// The input version string. + /// The expected sanitized version string. + [Theory] + [InlineData("v081326_QFE3", "081326_QFE3")] + [InlineData("vweekly-2026-08-14", "weekly-2026-08-14")] + [InlineData("v02-08-2026", "02-08-2026")] + [InlineData("V1.04", "1.04")] + [InlineData("1.08", "1.08")] + [InlineData(" v1.04 ", "1.04")] + [InlineData(" 1.08 ", "1.08")] + public void SetupWizardItemViewModel_Version_StripsLeadingVPrefix(string rawVersion, string expectedVersion) + { + var item = new SetupWizardItemViewModel + { + Version = rawVersion, + }; + + Assert.Equal(expectedVersion, item.Version); + } + private static ProfileResourceService CreateProfileResourceService() { return new ProfileResourceService(NullLogger.Instance); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs new file mode 100644 index 000000000..f5e4fa111 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModelTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using GenHub.Features.GameProfiles.ViewModels.Wizard; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels.Wizard; + +/// +/// Unit tests for . +/// +public class SetupWizardViewModelTests +{ + /// + /// Verifies that the constructor initializes labels and items accurately. + /// + [Fact] + public void Constructor_InitializesLabelsAndItemsCorrectly() + { + var items = new List + { + new() { Title = "Item 1", IsSelected = true, IsMandatory = false }, + new() { Title = "Item 2", IsSelected = true, IsMandatory = false }, + new() { Title = "Item 3", IsSelected = false, IsMandatory = false }, + }; + + var vm = new SetupWizardViewModel(items); + + Assert.Equal(3, vm.Items.Count); + Assert.Equal("Setup Detected Content", vm.Title); + Assert.Equal("Skip", vm.CancelLabel); + Assert.Equal("Continue (2)", vm.ConfirmLabel); + Assert.False(vm.Confirmed); + } + + /// + /// Verifies that ToggleSelectionCommand toggles item selection for non-mandatory items. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemNonMandatory_TogglesSelectionAndUpdatesLabel() + { + var item1 = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true, IsMandatory = false }; + var item2 = new SetupWizardItemViewModel { Title = "Item 2", IsSelected = false, IsMandatory = false }; + var vm = new SetupWizardViewModel([item1, item2]); + + Assert.Equal("Continue (1)", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(item1); + + Assert.False(item1.IsSelected); + Assert.Equal("Continue", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(item2); + + Assert.True(item2.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ToggleSelectionCommand ignores mandatory items. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemMandatory_DoesNotToggleSelection() + { + var mandatoryItem = new SetupWizardItemViewModel { Title = "Mandatory Item", IsSelected = true, IsMandatory = true }; + var vm = new SetupWizardViewModel([mandatoryItem]); + + Assert.Equal("Continue (1)", vm.ConfirmLabel); + + vm.ToggleSelectionCommand.Execute(mandatoryItem); + + Assert.True(mandatoryItem.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ToggleSelectionCommand does nothing when item is null. + /// + [Fact] + public void ToggleSelectionCommand_WhenItemNull_DoesNothing() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true, IsMandatory = false }; + var vm = new SetupWizardViewModel([item]); + + vm.ToggleSelectionCommand.Execute(null); + + Assert.True(item.IsSelected); + Assert.Equal("Continue (1)", vm.ConfirmLabel); + } + + /// + /// Verifies that ConfirmCommand sets Confirmed to true and signals close. + /// + [Fact] + public void ConfirmCommand_SetsConfirmedAndFiresCloseRequested() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true }; + var vm = new SetupWizardViewModel([item]); + var closeFired = false; + vm.CloseRequested += (_, _) => closeFired = true; + + vm.ConfirmCommand.Execute(null); + + Assert.True(vm.Confirmed); + Assert.True(closeFired); + } + + /// + /// Verifies that CancelCommand sets Confirmed to false and signals close. + /// + [Fact] + public void CancelCommand_SetsConfirmedFalseAndFiresCloseRequested() + { + var item = new SetupWizardItemViewModel { Title = "Item 1", IsSelected = true }; + var vm = new SetupWizardViewModel([item]); + var closeFired = false; + vm.CloseRequested += (_, _) => closeFired = true; + + vm.CancelCommand.Execute(null); + + Assert.False(vm.Confirmed); + Assert.True(closeFired); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs index bfa746365..65682c712 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs @@ -782,7 +782,19 @@ private static bool TryCreateHardLink(string existingPath, string linkPath) ? CreateHardLinkWindows(linkPath, existingPath, IntPtr.Zero) : LinkUnix(existingPath, linkPath) == 0; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or EntryPointNotFoundException or DllNotFoundException) + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (EntryPointNotFoundException) + { + return false; + } + catch (DllNotFoundException) { return false; } @@ -803,7 +815,11 @@ private static bool DeleteSucceeds(string path) File.Delete(path); return !File.Exists(path); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) { return false; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs index ac9b2e0c4..d62676a6d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/FileOperationsServiceTests.cs @@ -469,7 +469,15 @@ private static bool TryCreateSymbolicLink(string linkPath, string targetPath) File.CreateSymbolicLink(linkPath, targetPath); return true; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (PlatformNotSupportedException) { return false; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs index 18a3b4b3a..18ce626d7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Linux/Gameinstallations/SteamInstallationTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Models.Enums; using GenHub.Linux.GameInstallations; +using GenHub.Tests.Linux.Infrastructure.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; namespace GenHub.Tests.Linux.Gameinstallations; @@ -7,6 +8,7 @@ namespace GenHub.Tests.Linux.Gameinstallations; /// /// Unit tests for . /// +[Collection(ApplicationCompositionCollection.Name)] public class SteamInstallationTests { /// @@ -39,4 +41,75 @@ public void Constructor_WithFetch_RunsWithoutException() var exception = Record.Exception(() => new SteamInstallation(true, NullLogger.Instance)); Assert.Null(exception); } + + /// + /// Verifies SetPaths sets Generals and Zero Hour paths properly. + /// + [Fact] + public void SetPaths_SetsGeneralsAndZeroHourPaths() + { + var installation = new SteamInstallation(NullLogger.Instance); + installation.SetPaths("/home/user/games/Generals", "/home/user/games/ZeroHour"); + + Assert.True(installation.HasGenerals); + Assert.Equal("/home/user/games/Generals", installation.GeneralsPath); + Assert.True(installation.HasZeroHour); + Assert.Equal("/home/user/games/ZeroHour", installation.ZeroHourPath); + } + + /// + /// Verifies PopulateGameClients adds clients to AvailableGameClients. + /// + [Fact] + public void PopulateGameClients_AddsClientsSuccessfully() + { + var installation = new SteamInstallation(NullLogger.Instance); + var clients = new[] + { + new GenHub.Core.Models.GameClients.GameClient { Id = "test-client-1", Name = "Client 1" }, + }; + + installation.PopulateGameClients(clients); + + Assert.Single(installation.AvailableGameClients); + Assert.Equal("test-client-1", installation.AvailableGameClients[0].Id); + } + + /// + /// Verifies Fetch detects Flatpak Steam game installations from mock home directory. + /// + [Fact] + public void Fetch_WithFlatpakSteamDirectory_DetectsGameInstallation() + { + var tempHome = Path.Combine(Path.GetTempPath(), "genhub_test_home_" + Guid.NewGuid().ToString("N")); + var originalHome = Environment.GetEnvironmentVariable("HOME"); + + try + { + var gameDir = Path.Combine( + tempHome, + ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common", + GenHub.Core.Constants.GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen); + + Directory.CreateDirectory(gameDir); + File.WriteAllText(Path.Combine(gameDir, "generals.exe"), "mock exe content"); + + Environment.SetEnvironmentVariable("HOME", tempHome); + + var installation = new SteamInstallation(NullLogger.Instance); + installation.Fetch(); + + Assert.True(installation.IsSteamInstalled); + Assert.True(installation.HasZeroHour); + Assert.Equal(gameDir, installation.ZeroHourPath); + } + finally + { + Environment.SetEnvironmentVariable("HOME", originalHome); + if (Directory.Exists(tempHome)) + { + Directory.Delete(tempHome, true); + } + } + } } \ No newline at end of file diff --git a/GenHub/GenHub/Common/Services/UserSettingsService.cs b/GenHub/GenHub/Common/Services/UserSettingsService.cs index 953cce947..51d728522 100644 --- a/GenHub/GenHub/Common/Services/UserSettingsService.cs +++ b/GenHub/GenHub/Common/Services/UserSettingsService.cs @@ -136,7 +136,7 @@ public async Task TryUpdateAndSaveAsync(Func applyChan { ArgumentNullException.ThrowIfNull(applyChanges); - bool accepted; + var accepted = false; lock (_lock) { accepted = applyChanges(_settings); @@ -175,8 +175,8 @@ public async Task TryUpdateAndSaveAsync(Func applyChan /// public async Task SaveAsync(CancellationToken cancellationToken = default) { - UserSettings settingsToSave; - SettingsFileTarget target; + var settingsToSave = new UserSettings(); + var target = SettingsFileTarget.Unverified(string.Empty); lock (_lock) { target = _target; diff --git a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs index 7cffd1500..57a8ddc43 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/DependencyResolver.cs @@ -23,6 +23,72 @@ public class DependencyResolver( private readonly IContentManifestPool _manifestPool = manifestPool ?? throw new ArgumentNullException(nameof(manifestPool)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + /// + /// Matches a declared catalog ID to an acquired manifest ID allowing version and variant differences. + /// + /// The declared catalog ID. + /// The acquired manifest ID. + /// if identities are compatible; otherwise, . + public static bool HasCompatibleCatalogIdentity(string? declaredId, string? acquiredId) + { + if (string.IsNullOrWhiteSpace(declaredId) || string.IsNullOrWhiteSpace(acquiredId)) + { + return false; + } + + if (string.Equals(declaredId, acquiredId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var declaredParts = declaredId.Split('.'); + var acquiredParts = acquiredId.Split('.'); + + return HasCompatibleCatalogIdentity(declaredParts, acquiredParts); + } + + /// + /// Matches a declared 5-segment catalog ID (schemaVersion.userVersion.publisher.contentType.contentName) + /// to an acquired manifest ID. Requires schemaVersion (segment 0), publisher (segment 2, or wildcard any), + /// and contentType (segment 3) to match, while allowing userVersion (segment 1) and trailing variant labels + /// (e.g. -720p on contentName segment 4) to differ. + /// + /// The 5 segments of the declared catalog ID. + /// The 5 segments of the acquired manifest ID. + /// if identities are compatible; otherwise, . + public static bool HasCompatibleCatalogIdentity(string[] declaredParts, string[] acquiredParts) + { + if (declaredParts.Length != ManifestConstants.MinManifestSegments || acquiredParts.Length != ManifestConstants.MinManifestSegments) + { + return false; + } + + if (!declaredParts[0].Equals(acquiredParts[0], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var isAnyPublisher = declaredParts[2].Equals(ManifestConstants.AnyPublisherToken, StringComparison.OrdinalIgnoreCase); + if (!isAnyPublisher && !declaredParts[2].Equals(acquiredParts[2], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!declaredParts[3].Equals(acquiredParts[3], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var declaredName = declaredParts[4]; + var acquiredName = acquiredParts[4]; + if (declaredName.Equals(acquiredName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return acquiredName.StartsWith(declaredName + ManifestConstants.VariantSeparator, StringComparison.OrdinalIgnoreCase); + } + /// public async Task> ResolveDependenciesAsync(IEnumerable contentIds, CancellationToken cancellationToken = default) { diff --git a/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs b/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs index 93d6a3ca7..5ad4510fc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/SetupWizardService.cs @@ -74,7 +74,7 @@ public async Task RunSetupWizardAsync(IEnumerable x.Client != null && string.Equals((string)x.Client.Version, latestVersion, StringComparison.OrdinalIgnoreCase)); + .FirstOrDefault(x => x.Client != null && string.Equals(CleanVersionString((string)x.Client.Version), latestVersion, StringComparison.OrdinalIgnoreCase)); if (upToDateManaged != null) { @@ -118,7 +118,7 @@ public async Task RunSetupWizardAsync(IEnumerable RunSetupWizardAsync(IEnumerable RunSetupWizardAsync(IEnumerable GetLatestVersionAsync(string publisher) { try diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs index 0931ffe2f..2dcd4b0d7 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileLauncherViewModel.cs @@ -63,6 +63,8 @@ public partial class GameProfileLauncherViewModel( private readonly SemaphoreSlim _launchSemaphore = new(1, 1); private readonly System.Timers.Timer _headerCollapseTimer = new(TimeIntervals.HeaderCollapseDelayMs); private readonly System.Timers.Timer _headerExpansionTimer = new(TimeIntervals.HeaderExpansionDelayMs); + private bool _isHovering; + private bool _isTimersConfigured; private bool _lastOperationSuccess; private string? _expectedProfileIdForSuccess; private bool _isCreatingNewProfile; @@ -118,31 +120,39 @@ partial void OnSelectedProfileChanged(GameProfileItemViewModel? value) /// A representing the asynchronous operation. public virtual async Task InitializeAsync() { - // Reset header state on initialization/activation - ResetHeaderState(); + // On app launch, the header is expanded and persists without auto-collapsing + IsHeaderExpanded = true; + _isHovering = false; try { - // Set up timer - _headerCollapseTimer.AutoReset = false; - _headerCollapseTimer.Elapsed += (s, e) => - Avalonia.Threading.Dispatcher.UIThread.Invoke(() => IsHeaderExpanded = false); - - _headerCollapseTimer.Start(); - - // Set up expansion timer - _headerExpansionTimer.AutoReset = false; - _headerExpansionTimer.Elapsed += (s, e) => - Avalonia.Threading.Dispatcher.UIThread.Invoke(() => - { - IsHeaderExpanded = true; - _isHovering = true; + if (!_isTimersConfigured) + { + _isTimersConfigured = true; - // Stop collapse timer just in case - _headerCollapseTimer.Stop(); - }); + // Set up timer + _headerCollapseTimer.AutoReset = false; + _headerCollapseTimer.Elapsed += (s, e) => + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + if (!_isHovering && !IsScanning) + { + IsHeaderExpanded = false; + } + }); + + // Set up expansion timer + _headerExpansionTimer.AutoReset = false; + _headerExpansionTimer.Elapsed += (s, e) => + Avalonia.Threading.Dispatcher.UIThread.Invoke(() => + { + IsHeaderExpanded = true; + _isHovering = true; + _headerCollapseTimer.Stop(); + }); - gameProcessManager.ProcessExited += OnProcessExited; + gameProcessManager.ProcessExited += OnProcessExited; + } StatusMessage = "Loading profiles..."; ErrorMessage = string.Empty; @@ -312,10 +322,8 @@ public void OnTabActivated() ResetHeaderState(); } - private bool _isHovering; - /// - /// Resets the header state to expanded and restarts the auto-collapse timer. + /// Resets the header state to expanded and starts the auto-collapse timer. /// public void ResetHeaderState() { @@ -324,7 +332,7 @@ public void ResetHeaderState() _headerExpansionTimer.Stop(); // Only start the auto-collapse timer if the user is NOT currently hovering - if (!_isHovering) + if (!_isHovering && !IsScanning) { _headerCollapseTimer.Start(); } @@ -542,6 +550,12 @@ private async Task ApplyInstallationWizardDecisionsAsync( List installationsList, SetupWizardResult wizardResult) { + if (!wizardResult.Confirmed) + { + logger.LogInformation("Setup wizard was skipped by user, skipping profile creation"); + return 0; + } + var cpDecision = wizardResult.CommunityPatchAction; var goDecision = wizardResult.GeneralsOnlineAction; var shDecision = wizardResult.SuperHackersAction; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index fd7e36b13..59a022f53 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -419,8 +419,6 @@ private async Task SaveAsync() StatusMessage = "Profile created successfully"; _logger?.LogInformation("Created new profile {ProfileName} with {ContentCount} enabled content items", Name, enabledContentIds.Count); - WeakReferenceMessenger.Default.Send(new ProfileCreatedMessage(result.Data)); - ExecuteCancel(); } else diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index 3da6eb041..c1834d177 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -36,7 +36,7 @@ public virtual async Task InitializeForNewProfileAsync() } CurrentProfileId = null; - Name = "New Profile"; + Name = ProfileConstants.DefaultProfileName; Description = "A new game profile"; ColorValue = "#1976D2"; SelectedWorkspaceStrategy = GetDefaultWorkspaceStrategy(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs index 38db92e52..a6c666191 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.Messaging; @@ -17,6 +18,7 @@ using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; +using GenHub.Features.GameProfiles.Services; using GenHub.Features.Notifications.Services; using GenHub.Features.Notifications.ViewModels; using Microsoft.Extensions.Logging; @@ -168,19 +170,19 @@ private static void ValidateSingleDependencyWarning( if (dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId) { - bool found = manifestsById.ContainsKey(dependency.Id.ToString()); - if (!found && !dependency.StrictPublisher) + var declaredId = dependency.Id.ToString(); + bool found = manifestsById.ContainsKey(declaredId); + if (!found) { - var depIdSegments = dependency.Id.ToString().Split('.'); - if (depIdSegments.Length >= 5) + var depIdSegments = declaredId.Split('.'); + found = potentialMatches.Any(m => { - var (depType, depName) = (depIdSegments[3], depIdSegments[4]); - found = potentialMatches.Any(m => - { - var segments = m.Id.ToString().Split('.'); - return segments.Length >= 5 && segments[3].Equals(depType, StringComparison.OrdinalIgnoreCase) && segments[4].Equals(depName, StringComparison.OrdinalIgnoreCase); - }); - } + var segments = m.Id.ToString().Split('.'); + return HasCompatibleCatalogMatch(declaredId, m.Id.ToString()) || + (!dependency.StrictPublisher && segments.Length >= 5 && depIdSegments.Length >= 5 && + segments[3].Equals(depIdSegments[3], StringComparison.OrdinalIgnoreCase) && + segments[4].Equals(depIdSegments[4], StringComparison.OrdinalIgnoreCase)); + }); } if (!found && !dependency.IsOptional) warnings.Add($"'{manifest.Name}' requires '{dependency.Name}' which is not enabled."); @@ -193,6 +195,9 @@ private static void ValidateSingleDependencyWarning( } } + private static bool HasCompatibleCatalogMatch(string declaredId, string availableId) => + DependencyResolver.HasCompatibleCatalogIdentity(declaredId, availableId); + private readonly IGameProfileManager? _gameProfileManager; private readonly IGameSettingsService? _gameSettingsService; private readonly IConfigurationProviderService? _configurationProvider; @@ -399,67 +404,109 @@ partial void OnSelectedGameInstallationChanged(ContentDisplayItem? value) private async Task OnContentTypeChangedAsync() => await LoadAvailableContentAsync(); - private async Task EnableContentInternal(ContentDisplayItem? contentItem, bool bypassLoadingGuard = false) + private async Task EnableContentInternal( + ContentDisplayItem? contentItem, + bool bypassLoadingGuard = false, + bool isRootOperation = true, + List? autoEnabledNames = null, + CancellationToken cancellationToken = default) + { + if (!CanEnableContent(contentItem, bypassLoadingGuard)) + { + return; + } + + ReplaceConflictingEnabledContent(contentItem!); + ActivateContentItem(contentItem!); + + var autoResolved = autoEnabledNames ?? []; + await ResolveDependenciesAsync(contentItem!, autoResolved, cancellationToken); + + if (isRootOperation) + { + await HandleRootOperationCompletionAsync(contentItem!, autoResolved, cancellationToken); + } + } + + private bool CanEnableContent(ContentDisplayItem? contentItem, bool bypassLoadingGuard) { - if (contentItem == null) return; - if (IsLoadingContent && !bypassLoadingGuard) return; + if (contentItem == null || (IsLoadingContent && !bypassLoadingGuard)) + { + return false; + } + + if (contentItem.ContentType == ContentType.GameInstallation && SelectedGameInstallation == contentItem && contentItem.IsEnabled) + { + return false; + } + if (contentItem.IsLocked) { StatusMessage = "This content item is locked and cannot be modified"; - return; + return false; } if (!contentItem.CanToggle) { StatusMessage = "This content item cannot be toggled"; - return; + return false; } - if (contentItem.IsEnabled) return; + if (contentItem.IsEnabled || EnabledContent.Any(e => e.ManifestId.Value == contentItem.ManifestId.Value)) + { + return false; + } + + return true; + } - var alreadyEnabled = EnabledContent.FirstOrDefault(e => e.ManifestId.Value == contentItem.ManifestId.Value); - if (alreadyEnabled != null) return; + private void ReplaceConflictingEnabledContent(ContentDisplayItem contentItem) + { + if (contentItem.ContentType != ContentType.GameInstallation && contentItem.ContentType != ContentType.GameClient) + { + return; + } - if (contentItem.ContentType == ContentType.GameInstallation || contentItem.ContentType == ContentType.GameClient) + var existingItems = EnabledContent.Where(e => e.ContentType == contentItem.ContentType).ToList(); + foreach (var existing in existingItems) { - var existingItems = EnabledContent.Where(e => e.ContentType == contentItem.ContentType).ToList(); - foreach (var existing in existingItems) + if (existing.ContentType == ContentType.GameClient && Name == existing.DisplayName) { - if (existing.ContentType == ContentType.GameClient && Name == existing.DisplayName) - { - Name = "New Profile"; - } + Name = ProfileConstants.DefaultProfileName; + } - existing.IsEnabled = false; - EnabledContent.Remove(existing); + existing.IsEnabled = false; + EnabledContent.Remove(existing); - if (existing.ContentType == SelectedContentType && existing.GameType == GameTypeFilter) + if (existing.ContentType == SelectedContentType && existing.GameType == GameTypeFilter) + { + var alreadyInAvailable = AvailableContent.FirstOrDefault(a => a.ManifestId.Value == existing.ManifestId.Value); + if (alreadyInAvailable == null) { - var alreadyInAvailable = AvailableContent.FirstOrDefault(a => a.ManifestId.Value == existing.ManifestId.Value); - if (alreadyInAvailable == null) + AvailableContent.Add(new ContentDisplayItem { - AvailableContent.Add(new ContentDisplayItem - { - ManifestId = existing.ManifestId, - DisplayName = existing.DisplayName, - ContentType = existing.ContentType, - GameType = existing.GameType, - InstallationType = existing.InstallationType, - Publisher = existing.Publisher, - IsEnabled = false, - SourceId = existing.SourceId, - GameClientId = existing.GameClientId, - Version = existing.Version, - IsEditable = existing.IsEditable, - SourcePath = existing.SourcePath, - IsLocked = existing.IsLocked, - CanToggle = existing.CanToggle, - }); - } + ManifestId = existing.ManifestId, + DisplayName = existing.DisplayName, + ContentType = existing.ContentType, + GameType = existing.GameType, + InstallationType = existing.InstallationType, + Publisher = existing.Publisher, + IsEnabled = false, + SourceId = existing.SourceId, + GameClientId = existing.GameClientId, + Version = existing.Version, + IsEditable = existing.IsEditable, + SourcePath = existing.SourcePath, + IsLocked = existing.IsLocked, + CanToggle = existing.CanToggle, + }); } } } + } + private void ActivateContentItem(ContentDisplayItem contentItem) + { contentItem.IsEnabled = true; EnabledContent.Add(contentItem); @@ -477,28 +524,39 @@ private async Task EnableContentInternal(ContentDisplayItem? contentItem, bool b StatusMessage = $"Enabled {contentItem.DisplayName}"; _logger?.LogInformation("Enabled content {ContentName} for profile", contentItem.DisplayName); - _localNotificationService.ShowSuccess( - "Content Enabled", - $"Enabled '{contentItem.DisplayName}'"); - - if (contentItem.ContentType == ContentType.GameClient && Name == "New Profile") + if (contentItem.ContentType == ContentType.GameClient && Name == ProfileConstants.DefaultProfileName) { Name = contentItem.DisplayName; } + } + + private async Task HandleRootOperationCompletionAsync(ContentDisplayItem contentItem, List autoResolved, CancellationToken cancellationToken = default) + { + if (autoResolved.Count > 0) + { + _localNotificationService.ShowSuccess( + "Content Enabled", + $"Enabled '{contentItem.DisplayName}' and auto-resolved: {string.Join(", ", autoResolved)}"); + } + else + { + _localNotificationService.ShowSuccess( + "Content Enabled", + $"Enabled '{contentItem.DisplayName}'"); + } - await ResolveDependenciesAsync(contentItem); + await ValidateEnabledContentDependenciesAsync(contentItem.DisplayName, cancellationToken); } - private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) + private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem, List autoEnabledNames, CancellationToken cancellationToken = default) { try { if (_manifestPool == null) return; - var manifest = await GetOrSynthesizeManifestForContentAsync(contentItem); + var manifest = await GetOrSynthesizeManifestForContentAsync(contentItem, cancellationToken); if (manifest?.Dependencies == null || manifest.Dependencies.Count == 0) { - _ = ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); return; } @@ -506,31 +564,28 @@ private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) { if (dependency.DependencyType == ContentType.GameInstallation) { - await ResolveGameInstallationDependencyAsync(contentItem, dependency); + await ResolveGameInstallationDependencyAsync(contentItem, dependency, autoEnabledNames, cancellationToken); } else { - await ResolveContentDependencyAsync(dependency); + await ResolveContentDependencyAsync(dependency, autoEnabledNames, cancellationToken); } } - - await ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); } catch (Exception ex) { _logger?.LogError(ex, "Error resolving dependencies for {ContentName}", contentItem.DisplayName); - _ = ValidateEnabledContentDependenciesAsync(contentItem.DisplayName); } } - private async Task GetOrSynthesizeManifestForContentAsync(ContentDisplayItem contentItem) + private async Task GetOrSynthesizeManifestForContentAsync(ContentDisplayItem contentItem, CancellationToken cancellationToken = default) { if (_manifestPool == null) { return null; } - var manifestResult = await _manifestPool.GetManifestAsync(contentItem.ManifestId.Value); + var manifestResult = await _manifestPool.GetManifestAsync(ManifestId.Create(contentItem.ManifestId.Value), cancellationToken); if (manifestResult.Success && manifestResult.Data != null) { return manifestResult.Data; @@ -561,7 +616,11 @@ private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem) return null; } - private async Task ResolveGameInstallationDependencyAsync(ContentDisplayItem contentItem, ContentDependency dependency) + private async Task ResolveGameInstallationDependencyAsync( + ContentDisplayItem contentItem, + ContentDependency dependency, + List autoEnabledNames, + CancellationToken cancellationToken = default) { bool isSatisfied = false; var isDefaultDep = dependency.Id.ToString() == ManifestConstants.DefaultContentDependencyId; @@ -607,15 +666,25 @@ private async Task ResolveGameInstallationDependencyAsync(ContentDisplayItem con if (compatibleInstallation != null) { - _localNotificationService.ShowSuccess("Auto-Resolved", $"Switched Game Installation to '{compatibleInstallation.DisplayName}' as required by '{contentItem.DisplayName}'."); - await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true); + if (!autoEnabledNames.Contains(compatibleInstallation.DisplayName)) + { + autoEnabledNames.Add(compatibleInstallation.DisplayName); + } + + await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); } } - private async Task ResolveContentDependencyAsync(ContentDependency dependency) + private async Task ResolveContentDependencyAsync( + ContentDependency dependency, + List autoEnabledNames, + CancellationToken cancellationToken = default) { - bool alreadyEnabled = dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId - ? EnabledContent.Any(x => x.ManifestId.Value == dependency.Id.ToString()) + var declaredId = dependency.Id.ToString(); + bool alreadyEnabled = declaredId != ManifestConstants.DefaultContentDependencyId + ? EnabledContent.Any(x => x.ManifestId.Value == declaredId || + (x.ContentType == dependency.DependencyType && + HasCompatibleCatalogMatch(declaredId, x.ManifestId.Value))) : EnabledContent.Any(x => x.ContentType == dependency.DependencyType); if (alreadyEnabled || dependency.IsOptional || _profileContentLoader == null) return; @@ -632,24 +701,27 @@ private async Task ResolveContentDependencyAsync(ContentDependency dependency) })), EnabledContent.Select(x => x.ManifestId.Value)); - Core.Models.Content.ContentDisplayItem? match = null; - if (dependency.Id.ToString() != ManifestConstants.DefaultContentDependencyId) - { - match = availableOfTargetType.FirstOrDefault(x => x.ManifestId == dependency.Id.ToString()); - } + var match = declaredId != ManifestConstants.DefaultContentDependencyId + ? (availableOfTargetType.FirstOrDefault(x => x.ManifestId == declaredId) + ?? availableOfTargetType.FirstOrDefault(x => HasCompatibleCatalogMatch(declaredId, x.ManifestId))) + : availableOfTargetType.FirstOrDefault(x => x.ContentType == dependency.DependencyType); if (match != null) { var viewModelItem = ConvertToViewModelContentDisplayItem(match); if (!viewModelItem.IsEnabled) { - _localNotificationService.ShowSuccess("Auto-Resolved", $"Automatically enabled required content: '{viewModelItem.DisplayName}'"); - await EnableContentInternal(viewModelItem, bypassLoadingGuard: true); + if (!autoEnabledNames.Contains(viewModelItem.DisplayName)) + { + autoEnabledNames.Add(viewModelItem.DisplayName); + } + + await EnableContentInternal(viewModelItem, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); } } } - private async Task ValidateEnabledContentDependenciesAsync(string justEnabledContentName) + private async Task ValidateEnabledContentDependenciesAsync(string justEnabledContentName, CancellationToken cancellationToken = default) { try { @@ -660,7 +732,7 @@ private async Task ValidateEnabledContentDependenciesAsync(string justEnabledCon var manifests = new List(); foreach (var manifestId in enabledManifestIds) { - var manifestResult = await _manifestPool.GetManifestAsync(manifestId); + var manifestResult = await _manifestPool.GetManifestAsync(ManifestId.Create(manifestId), cancellationToken); if (manifestResult.Success && manifestResult.Data != null) manifests.Add(manifestResult.Data); } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs index a2cde11eb..37bc051cf 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardItemViewModel.cs @@ -64,7 +64,12 @@ public string Version get => _version; set { - var displayVersion = GameVersionHelper.IsDefaultVersion(value) ? string.Empty : value; + var displayVersion = GameVersionHelper.IsDefaultVersion(value) ? string.Empty : value?.Trim(); + if (!string.IsNullOrEmpty(displayVersion) && (displayVersion.StartsWith('v') || displayVersion.StartsWith('V'))) + { + displayVersion = displayVersion[1..]; + } + SetProperty(ref _version, displayVersion ?? string.Empty); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs index 90f6fe14e..87488271d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/Wizard/SetupWizardViewModel.cs @@ -27,7 +27,7 @@ public sealed partial class SetupWizardViewModel(IEnumerable [ObservableProperty] - private string _cancelLabel = "Skip & Create Base Profiles"; + private string _cancelLabel = "Skip"; /// /// Gets or sets the label for the confirm/continue button. @@ -45,11 +45,16 @@ public sealed partial class SetupWizardViewModel(IEnumerable _confirmed; [RelayCommand] - private void ToggleSelection(SetupWizardItemViewModel item) + private void ToggleSelection(SetupWizardItemViewModel? item) { + if (item == null) + { + return; + } + if (!item.IsMandatory) { - // IsSelected is bound two-way, so we just need to update the summary labels + item.IsSelected = !item.IsSelected; UpdateLabels(); } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml index 7f17168c6..14a717ad0 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileLauncherView.axaml @@ -245,32 +245,30 @@ + + + + - + diff --git a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs index c19938893..6370f2596 100644 --- a/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs +++ b/GenHub/GenHub/Features/GameSettings/GameSettingsService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using System.Text; using System.Text.Json; using System.Threading; @@ -70,99 +71,99 @@ public bool OptionsFileExists(GameType gameType) /// public async Task> LoadOptionsAsync(GameType gameType) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" }); - - // Acquire semaphore to prevent reading while writing - await _optionsIniWriteSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" })) { - var filePath = GetOptionsFilePath(gameType); - _logger.LogDebug("Loading from path: {FilePath}", filePath); - - if (!File.Exists(filePath)) + // Acquire semaphore to prevent reading while writing + await _optionsIniWriteSemaphore.WaitAsync(); + try { - _logger.LogWarning("File not found at {FilePath}, returning defaults", filePath); - return OperationResult.CreateSuccess(new IniOptions()); - } + var filePath = GetOptionsFilePath(gameType); + _logger.LogDebug("Loading from path: {FilePath}", filePath); + + if (!File.Exists(filePath)) + { + _logger.LogWarning("File not found at {FilePath}, returning defaults", filePath); + return OperationResult.CreateSuccess(new IniOptions()); + } - _logger.LogDebug("Reading file"); - var lines = await File.ReadAllLinesAsync(filePath); - _logger.LogDebug("Parsing {LineCount} lines", lines.Length); - var options = ParseOptionsIni(lines); + _logger.LogDebug("Reading file"); + var lines = await File.ReadAllLinesAsync(filePath); + _logger.LogDebug("Parsing {LineCount} lines", lines.Length); + var options = ParseOptionsIni(lines); - _logger.LogInformation("Loaded successfully from {FilePath}", filePath); - return OperationResult.CreateSuccess(options); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load Options.ini for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to load options: {ex.Message}"); - } - finally - { - _optionsIniWriteSemaphore.Release(); + _logger.LogInformation("Loaded successfully from {FilePath}", filePath); + return OperationResult.CreateSuccess(options); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException) + { + _logger.LogError(ex, "Failed to load Options.ini for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to load options: {ex.Message}"); + } + finally + { + _optionsIniWriteSemaphore.Release(); + } } } /// public async Task> SaveOptionsAsync(GameType gameType, IniOptions options) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" }); - - // Acquire semaphore to serialize Options.ini writes - await _optionsIniWriteSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "OptionsIni" })) { - var filePath = GetOptionsFilePath(gameType); - _logger.LogDebug("Saving to path: {FilePath}", filePath); + // Acquire semaphore to serialize Options.ini writes + await _optionsIniWriteSemaphore.WaitAsync(); + try + { + var filePath = GetOptionsFilePath(gameType); + _logger.LogDebug("Saving to path: {FilePath}", filePath); - var directory = Path.GetDirectoryName(filePath); + var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - _logger.LogDebug("Creating directory: {Directory}", directory); - Directory.CreateDirectory(directory); - _logger.LogInformation("Created directory {Directory}", directory); - } + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + _logger.LogDebug("Creating directory: {Directory}", directory); + Directory.CreateDirectory(directory); + _logger.LogInformation("Created directory {Directory}", directory); + } - // Safety check: Don't overwrite existing non-empty file with empty options - // This prevents data loss if a load failed but Save was called with defaults - if (File.Exists(filePath) && new FileInfo(filePath).Length > 0) - { - bool isDefault = options.Video.ResolutionWidth == 0 && options.Video.ResolutionHeight == 0; - if (isDefault) + // Safety check: Don't overwrite existing non-empty file with empty options + // This prevents data loss if a load failed but Save was called with defaults + if (File.Exists(filePath) && new FileInfo(filePath).Length > 0) { - _logger.LogWarning("Attempted to overwrite existing Options.ini with default empty settings. Aborting save to prevent data loss."); - return OperationResult.CreateFailure("Prevented overwriting Options.ini with default settings."); + bool isDefault = options.Video.ResolutionWidth == 0 && options.Video.ResolutionHeight == 0; + if (isDefault) + { + _logger.LogWarning("Attempted to overwrite existing Options.ini with default empty settings. Aborting save to prevent data loss."); + return OperationResult.CreateFailure("Prevented overwriting Options.ini with default settings."); + } } - } - _logger.LogDebug("Serializing options"); - var lines = SerializeOptionsIni(options); - _logger.LogDebug("Writing {LineCount} lines to file", lines.Length); - await File.WriteAllLinesAsync(filePath, lines, Encoding.UTF8); + _logger.LogDebug("Serializing options"); + var lines = SerializeOptionsIni(options); + _logger.LogDebug("Writing {LineCount} lines to file", lines.Length); + await File.WriteAllLinesAsync(filePath, lines, Encoding.UTF8); - _logger.LogInformation("Saved successfully to {FilePath}", filePath); - return OperationResult.CreateSuccess(true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save Options.ini for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to save options: {ex.Message}"); - } - finally - { - // Always release the semaphore - _optionsIniWriteSemaphore.Release(); + _logger.LogInformation("Saved successfully to {FilePath}", filePath); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException) + { + _logger.LogError(ex, "Failed to save Options.ini for {GameType}", gameType); + return OperationResult.CreateFailure($"Failed to save options: {ex.Message}"); + } + finally + { + // Always release the semaphore + _optionsIniWriteSemaphore.Release(); + } } } /// public async Task> LoadTheSuperHackersSettingsAsync(GameType gameType) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" }); - - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { var optionsResult = await LoadOptionsAsync(gameType); if (!optionsResult.Success || optionsResult.Data == null) @@ -181,19 +182,12 @@ public async Task> LoadTheSuperHackersS _logger.LogInformation("Loaded TheSuperHackers settings for {GameType}", gameType); return OperationResult.CreateSuccess(settings); } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load TheSuperHackers settings for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to load TheSuperHackers settings: {ex.Message}"); - } } /// public async Task> SaveTheSuperHackersSettingsAsync(GameType gameType, TheSuperHackersSettings settings) { - using var scope = _logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" }); - - try + using (_logger.BeginScope(new Dictionary { ["GameType"] = gameType, ["Section"] = "TheSuperHackers" })) { var optionsResult = await LoadOptionsAsync(gameType); if (!optionsResult.Success || optionsResult.Data == null) @@ -208,96 +202,93 @@ public async Task> SaveTheSuperHackersSettingsAsync(GameTy var saveResult = await SaveOptionsAsync(gameType, options); return saveResult; } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save TheSuperHackers settings for {GameType}", gameType); - return OperationResult.CreateFailure($"Failed to save TheSuperHackers settings: {ex.Message}"); - } } /// public async Task> LoadGeneralsOnlineSettingsAsync() { - using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); - - await _generalsOnlineSettingsSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" })) { - var settingsPath = GetGeneralsOnlineSettingsPath(); - _logger.LogDebug("Loading GeneralsOnline settings from: {SettingsPath}", settingsPath); - - if (!File.Exists(settingsPath)) + await _generalsOnlineSettingsSemaphore.WaitAsync(); + try { - _logger.LogWarning("GeneralsOnline settings file not found at {SettingsPath}, returning defaults", settingsPath); - return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); - } + var settingsPath = GetGeneralsOnlineSettingsPath(); + _logger.LogDebug("Loading GeneralsOnline settings from: {SettingsPath}", settingsPath); - var json = await File.ReadAllTextAsync(settingsPath); - var settings = JsonSerializer.Deserialize(json, _jsonSerializerOptions); + if (!File.Exists(settingsPath)) + { + _logger.LogWarning("GeneralsOnline settings file not found at {SettingsPath}, returning defaults", settingsPath); + return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + } - if (settings == null) - { - _logger.LogWarning("Failed to deserialize GeneralsOnline settings, returning defaults"); - return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); - } + var json = await File.ReadAllTextAsync(settingsPath); + var settings = JsonSerializer.Deserialize(json, _jsonSerializerOptions); - settings.EnsureNestedSectionsInitialized(); + if (settings == null) + { + _logger.LogWarning("Failed to deserialize GeneralsOnline settings, returning defaults"); + return OperationResult.CreateSuccess(new GeneralsOnlineSettings()); + } - _logger.LogInformation("Loaded GeneralsOnline settings from {SettingsPath}", settingsPath); - return OperationResult.CreateSuccess(settings); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load GeneralsOnline settings"); - return OperationResult.CreateFailure($"Failed to load GeneralsOnline settings: {ex.Message}"); - } - finally - { - _generalsOnlineSettingsSemaphore.Release(); + settings.EnsureNestedSectionsInitialized(); + + _logger.LogInformation("Loaded GeneralsOnline settings from {SettingsPath}", settingsPath); + return OperationResult.CreateSuccess(settings); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException or JsonException) + { + _logger.LogError(ex, "Failed to load GeneralsOnline settings"); + return OperationResult.CreateFailure($"Failed to load GeneralsOnline settings: {ex.Message}"); + } + finally + { + _generalsOnlineSettingsSemaphore.Release(); + } } } /// public async Task> SaveGeneralsOnlineSettingsAsync(GeneralsOnlineSettings settings) { - using var scope = _logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" }); - - string? temporaryPath = null; - await _generalsOnlineSettingsSemaphore.WaitAsync(); - try + using (_logger.BeginScope(new Dictionary { ["Section"] = "GeneralsOnline" })) { - var settingsPath = GetGeneralsOnlineSettingsPath(); - var directory = Path.GetDirectoryName(settingsPath); - - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + string? temporaryPath = null; + await _generalsOnlineSettingsSemaphore.WaitAsync(); + try { - _logger.LogDebug("Creating directory: {Directory}", directory); - Directory.CreateDirectory(directory); - } + var settingsPath = GetGeneralsOnlineSettingsPath(); + var directory = Path.GetDirectoryName(settingsPath); - var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + _logger.LogDebug("Creating directory: {Directory}", directory); + Directory.CreateDirectory(directory); + } - // Written beside settings.json under a name of its own and then moved over it. This - // file belongs to the GeneralsOnline client and holds keys GenHub cannot reconstruct, - // so a truncating write that is interrupted, or that overlaps a second launch writing - // the same path, would leave the client with a settings.json it cannot read. - temporaryPath = $"{settingsPath}.{Guid.NewGuid():N}{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}"; - await File.WriteAllTextAsync(temporaryPath, json, Encoding.UTF8); - await ReplaceSettingsFileAsync(temporaryPath, settingsPath); - temporaryPath = null; + var json = JsonSerializer.Serialize(settings, _jsonSerializerOptions); - _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); - return OperationResult.CreateSuccess(true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to save GeneralsOnline settings"); - return OperationResult.CreateFailure($"Failed to save GeneralsOnline settings: {ex.Message}"); - } - finally - { - DiscardTemporarySettingsFile(temporaryPath); - _generalsOnlineSettingsSemaphore.Release(); + // Written beside settings.json under a name of its own and then moved over it. This + // file belongs to the GeneralsOnline client and holds keys GenHub cannot reconstruct, + // so a truncating write that is interrupted, or that overlaps a second launch writing + // the same path, would leave the client with a settings.json it cannot read. + temporaryPath = $"{settingsPath}.{Guid.NewGuid():N}{GameSettingsGeneralsOnlineConstants.TemporarySettingsFileExtension}"; + await File.WriteAllTextAsync(temporaryPath, json, Encoding.UTF8); + await ReplaceSettingsFileAsync(temporaryPath, settingsPath); + temporaryPath = null; + + _logger.LogInformation("Saved GeneralsOnline settings to {SettingsPath}", settingsPath); + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or NotSupportedException or ArgumentException or InvalidOperationException or JsonException) + { + _logger.LogError(ex, "Failed to save GeneralsOnline settings"); + return OperationResult.CreateFailure($"Failed to save GeneralsOnline settings: {ex.Message}"); + } + finally + { + DiscardTemporarySettingsFile(temporaryPath); + _generalsOnlineSettingsSemaphore.Release(); + } } } @@ -324,7 +315,12 @@ private static void DiscardTemporarySettingsFile(string? temporaryPath) { File.Delete(temporaryPath); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (IOException) + { + // Best effort; a leftover temporary file is not worth failing the save over, and + // this runs in a finally block where throwing would hide the error being reported. + } + catch (UnauthorizedAccessException) { // Best effort; a leftover temporary file is not worth failing the save over, and // this runs in a finally block where throwing would hide the error being reported. @@ -551,6 +547,9 @@ private static void ParseAudioSection(AudioSettings audio, Dictionary Date: Thu, 20 Aug 2026 17:58:53 +0200 Subject: [PATCH 17/20] feat(content): implement manifest installation instructions execution and GeneralsOnline EAC registration (#399) --- .../Constants/GeneralsOnlineConstants.cs | 17 + .../Constants/PublisherTypeConstants.cs | 15 + GenHub/GenHub.Core/Helpers/PathHelper.cs | 95 +- .../IInstallationInstructionsService.cs | 32 + .../Content/IInstallationStepPrecondition.cs | 27 + .../Manifest/IContentManifestBuilder.cs | 52 +- .../GenHub.Core/Models/Common/UserSettings.cs | 29 + .../Models/Enums/InstallationStepKind.cs | 30 + .../GeneralsOnline/GeneralsOnlineRelease.cs | 6 + .../Manifest/InstallationInstructions.cs | 5 - .../Models/Manifest/InstallationStep.cs | 45 +- .../Content/BaseContentProviderTests.cs | 159 ++- .../Content/GitHubContentProviderTests.cs | 13 +- .../InstallationInstructionsServiceTests.cs | 1000 +++++++++++++++++ .../EasyAntiCheatPreconditionTests.cs | 137 +++ .../GeneralsOnlineDelivererTests.cs | 68 ++ .../GeneralsOnlineJsonCatalogParserTests.cs | 31 + .../GeneralsOnlineManifestFactoryEacTests.cs | 113 +- .../GeneralsOnlineManifestFactoryTests.cs | 29 + .../Publishers/SuperHackersProviderTests.cs | 14 +- .../GameProfileLauncherViewModelTests.cs | 3 +- .../Manifest/ContentManifestBuilderTests.cs | 54 + .../Helpers/PathHelperTests.cs | 101 ++ .../CommunityOutpostProvider.cs | 8 +- .../ContentDeliverers/FileSystemDeliverer.cs | 5 +- .../AODMapsContentProvider.cs | 4 +- .../ContentProviders/BaseContentProvider.cs | 210 +++- .../CNCLabsContentProvider.cs | 5 +- .../LocalFileSystemContentProvider.cs | 3 +- .../ContentProviders/ModDBContentProvider.cs | 5 +- .../EasyAntiCheatPrecondition.cs | 106 ++ .../GeneralsOnline/GeneralsOnlineDeliverer.cs | 13 +- .../GeneralsOnlineJsonCatalogParser.cs | 1 + .../GeneralsOnlineManifestFactory.cs | 325 ++++-- .../GeneralsOnline/GeneralsOnlineProvider.cs | 95 +- .../Services/GitHub/GitHubContentProvider.cs | 5 +- .../InstallationInstructionsService.cs | 657 +++++++++++ .../Publishers/SuperHackersProvider.cs | 5 +- .../Infrastructure/GameProcessManager.cs | 2 +- .../Manifest/ContentManifestBuilder.cs | 136 ++- .../ContentPipelineModule.cs | 6 + 41 files changed, 3371 insertions(+), 295 deletions(-) create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs create mode 100644 GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs create mode 100644 GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index dad16f47a..bebc15802 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -125,6 +125,23 @@ public static class GeneralsOnlineConstants /// Description for Generals Online deliverer. public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage"; + // ===== Easy Anti-Cheat Installation ===== + + /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online. + public const string EacProductId = "fc1cc0d936424212b645105f084d08b0"; + + /// Setup command passed to EasyAntiCheat_EOS_Setup.exe. + public const string EacInstallCommand = "install"; + + /// Display name for the Easy Anti-Cheat installation step. + public const string EacStepName = "Install Easy Anti-Cheat"; + + /// Status message displayed to the user during Easy Anti-Cheat installation. + public const string EacStatusMessage = "Installing AntiCheat"; + + /// Unique step key identifying Easy Anti-Cheat installation for Generals Online. + public const string EacStepKey = PublisherType + ":eac:" + EacProductId; + // ===== Content Tags ===== /// Content tags for search and categorization. diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..5ca6cabfb 100644 --- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using GenHub.Core.Extensions.GameInstallations; using GenHub.Core.Models.Enums; @@ -59,6 +61,19 @@ public static class PublisherTypeConstants /// Art of Defense Maps community site. public const string AODMaps = "aodmaps"; + /// GenHub internal system content publisher. + public const string GenHubInternal = "genhub"; + + /// + /// Set of publisher identifiers trusted to execute installation steps (e.g. installers). + /// + public static readonly IReadOnlySet TrustedExecutablePublishers = new HashSet(StringComparer.OrdinalIgnoreCase) + { + GeneralsOnline, + CommunityOutpost, + TheSuperHackers, + }; + /// /// Maps GameInstallationType enum to publisher type string. /// diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index b2bee2bfe..8b59e6eff 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -92,11 +92,41 @@ public static string GetSafeParentDirectory(string path) /// when the candidate resolves inside the base directory; otherwise, . public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath) { - var normalizedRoot = Path.GetFullPath(baseDirectory); - var normalizedTarget = Path.GetFullPath(candidatePath); + if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + try + { + var normalizedRoot = Path.GetFullPath(baseDirectory); + var normalizedTarget = Path.GetFullPath(candidatePath); - return IsContained(normalizedRoot, normalizedTarget) && - IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + return IsContained(normalizedRoot, normalizedTarget) && + IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget)); + } + catch + { + return false; + } + } + + /// + /// Normalizes a relative path by standardizing directory separators and removing leading separators. + /// + /// The relative path to normalize. + /// The normalized relative path. + public static string NormalizeRelativePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + { + return string.Empty; + } + + return relativePath + .Replace('\\', '/') + .TrimStart('/') + .Replace('/', Path.DirectorySeparatorChar); } private static bool IsContained(string normalizedRoot, string normalizedTarget) @@ -109,31 +139,52 @@ private static bool IsContained(string normalizedRoot, string normalizedTarget) !Path.IsPathRooted(relative); } - private static string FollowLinks(string fullPath) + private static string FollowLinks(string fullPath, int maxDepth = 32) { + if (maxDepth <= 0) + { + return fullPath; + } + try { - var existing = fullPath; - var remainder = string.Empty; + var normalized = Path.GetFullPath(fullPath); + var root = Path.GetPathRoot(normalized); + if (string.IsNullOrEmpty(root)) + { + return normalized; + } + + var relativeFromRoot = Path.GetRelativePath(root, normalized); + if (relativeFromRoot == "." || relativeFromRoot.Length == 0) + { + return root; + } + + var segments = relativeFromRoot.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); - while (!Directory.Exists(existing) && !File.Exists(existing)) + var current = root; + foreach (var segment in segments) { - var parent = Path.GetDirectoryName(existing); - if (string.IsNullOrEmpty(parent)) + current = Path.Combine(current, segment); + + if (Directory.Exists(current) || File.Exists(current)) { - return fullPath; - } + FileSystemInfo info = Directory.Exists(current) + ? new DirectoryInfo(current) + : new FileInfo(current); - remainder = Path.Combine(Path.GetFileName(existing), remainder); - existing = parent; + var target = info.ResolveLinkTarget(returnFinalTarget: true); + if (target != null) + { + current = FollowLinks(target.FullName, maxDepth - 1); + } + } } - FileSystemInfo info = Directory.Exists(existing) - ? new DirectoryInfo(existing) - : new FileInfo(existing); - var resolved = info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? existing; - - return remainder.Length == 0 ? resolved : Path.GetFullPath(Path.Combine(resolved, remainder)); + return Path.GetFullPath(current); } catch (IOException) { @@ -143,6 +194,10 @@ private static string FollowLinks(string fullPath) { return fullPath; } + catch (SecurityException) + { + return fullPath; + } catch (NotSupportedException) { return fullPath; diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs new file mode 100644 index 000000000..74f9cd2a3 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Service for validating and executing manifest-declared installation steps. +/// +public interface IInstallationInstructionsService +{ + /// + /// Executes post-installation steps for the specified manifest, optionally forcing run-once steps. + /// + /// The content manifest declaring post-installation steps. + /// The working directory containing the content files. + /// The provider source name supplying the content, used for step authorization. + /// Whether to force execution of steps marked as run-once even if already executed. + /// Optional progress reporter for acquisition status. + /// A token to cancel the operation. + /// A result indicating whether all post-installation steps succeeded. + Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + string? providerSource = null, + bool force = false, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs new file mode 100644 index 000000000..f4ef7d82f --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs @@ -0,0 +1,27 @@ +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Interfaces.Content; + +/// +/// Defines a precondition or environment check for an installation step. +/// Allows domain-specific probes (e.g. system service or installed anti-cheat detection) +/// to determine whether a step is already satisfied. +/// +public interface IInstallationStepPrecondition +{ + /// + /// Determines whether this precondition can handle the specified installation step. + /// + /// The installation step to inspect. + /// The content manifest declaring the step. + /// if this precondition applies to the step; otherwise, . + bool CanHandle(InstallationStep step, ContentManifest manifest); + + /// + /// Determines whether the step's goal is already fulfilled in the local environment. + /// + /// The installation step to evaluate. + /// The content manifest declaring the step. + /// if the step is already fulfilled; otherwise, . + bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest); +} diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs index e707f019e..58985d1e0 100644 --- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs +++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs @@ -86,6 +86,13 @@ public interface IContentManifestBuilder /// The builder instance for chaining. IContentManifestBuilder WithPublisher(string name, string website = "", string supportUrl = "", string contactEmail = "", string publisherType = ""); + /// + /// Sets publisher information from an existing instance. + /// + /// The publisher information. + /// The builder instance for chaining. + IContentManifestBuilder WithPublisher(PublisherInfo publisher); + /// /// Sets content metadata. /// @@ -207,26 +214,42 @@ IContentManifestBuilder AddDependency( IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy); /// - /// Adds a pre-installation step. + /// Sets the complete installation instructions object for the manifest. /// - /// Step name. - /// Command to execute. - /// Command arguments. - /// Working directory for the command. - /// Whether elevation is required. + /// The installation instructions object. /// The builder instance for chaining. - IContentManifestBuilder AddPreInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions); /// /// Adds a post-installation step. /// /// Step name. - /// Command to execute. - /// Command arguments. - /// Working directory for the command. + /// The kind of installation step to execute. + /// Target relative path within workspace. + /// Command arguments for executable steps. + /// Destination relative path for rename operations. /// Whether elevation is required. + /// Optional user-facing status message. + /// Whether to execute only once and skip on future updates. + /// Optional unique step key for tracking execution. /// The builder instance for chaining. - IContentManifestBuilder AddPostInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false); + IContentManifestBuilder AddPostInstallStep( + string name, + InstallationStepKind kind, + string? targetRelativePath = null, + List? arguments = null, + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null); + + /// + /// Adds a post-installation step using an existing instance. + /// + /// The installation step to add. + /// The builder instance for chaining. + IContentManifestBuilder AddPostInstallStep(InstallationStep step); /// /// Adds a content reference for cross-publisher linking. @@ -244,6 +267,13 @@ IContentManifestBuilder AddContentReference( string minVersion = "", string maxVersion = ""); + /// + /// Sets content references for cross-publisher linking. + /// + /// The collection of content references. + /// The builder instance for chaining. + IContentManifestBuilder WithContentReferences(IEnumerable contentReferences); + /// /// Adds a file patching operation to the manifest. /// diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index c33263307..fd4c33fe7 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -95,6 +95,11 @@ public class UserSettings /// public CasConfiguration CasConfiguration { get; set; } = new(); + /// + /// Gets or sets the collection of installation step keys that have been executed on this machine. + /// + public HashSet ExecutedInstallationSteps { get; set; } = []; + /// Marks a property as explicitly set by the user. /// The name of the property to mark as explicitly set. public void MarkAsExplicitlySet(string propertyName) @@ -102,6 +107,29 @@ public void MarkAsExplicitlySet(string propertyName) ExplicitlySetProperties.Add(propertyName); } + /// + /// Checks whether an installation step key has already been recorded as executed. + /// + /// The unique installation step key. + /// if already executed; otherwise, . + public bool IsInstallationStepExecuted(string stepKey) + { + return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey); + } + + /// + /// Records that an installation step key has been executed. + /// + /// The unique installation step key. + public void RecordInstallationStepExecuted(string stepKey) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + ExecutedInstallationSteps ??= []; + ExecutedInstallationSteps.Add(stepKey); + } + } + /// Checks if a property was explicitly set by the user. /// The name of the property to check. /// true if the property was explicitly set by the user; otherwise, false. @@ -181,6 +209,7 @@ public UserSettings Clone() UseInstallationAdjacentStorage = UseInstallationAdjacentStorage, ExplicitlySetProperties = [.. ExplicitlySetProperties], CasConfiguration = (CasConfiguration?)CasConfiguration?.Clone() ?? new CasConfiguration(), + ExecutedInstallationSteps = ExecutedInstallationSteps != null ? [.. ExecutedInstallationSteps] : [], SkippedUpdateVersions = SkippedUpdateVersions != null ? new Dictionary(SkippedUpdateVersions) : [], PreferredUpdateStrategy = PreferredUpdateStrategy, PublisherSubscriptions = PublisherSubscriptions != null diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs new file mode 100644 index 000000000..1389e130b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Enums; + +/// +/// Defines the supported kind of installation operation in manifest-declared installation steps. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum InstallationStepKind +{ + /// + /// Installation step kind is unknown or undefined (default). + /// + Unknown = 0, + + /// + /// Runs a verified installer executable that exists within the manifest and workspace. + /// + RunVerifiedInstaller = 1, + + /// + /// Removes a file within the workspace. + /// + RemoveFile = 2, + + /// + /// Renames or moves a file within the workspace. + /// + RenameFile = 3, +} diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs index fd42ee2b2..b3a98df3a 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs @@ -36,6 +36,12 @@ public class GeneralsOnlineRelease /// public long? PortableSize { get; init; } + /// + /// Gets SHA256 hash of the portable ZIP package for file verification. + /// Null when hash is unknown (e.g., from latest.txt API). + /// + public string? Sha256 { get; init; } + /// /// Gets release changelog/notes. /// diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs index 1c5e1dba6..b954fcdbd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs @@ -10,11 +10,6 @@ namespace GenHub.Core.Models.Manifest; /// public class InstallationInstructions { - /// - /// Gets or sets the steps to run before installation. - /// - public List PreInstallSteps { get; set; } = []; - /// /// Gets or sets the steps to run after installation. /// diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs index 6ecd505a9..78590ebfd 100644 --- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs +++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using GenHub.Core.Models.Enums; + namespace GenHub.Core.Models.Manifest; /// -/// Individual installation step with commands and conditions. +/// Individual installation step with typed operation kind and structured parameters. /// public class InstallationStep { @@ -11,22 +15,49 @@ public class InstallationStep public string Name { get; set; } = string.Empty; /// - /// Gets or sets the command to execute. + /// Gets or sets the kind of installation operation to execute. + /// + public InstallationStepKind Kind { get; set; } = InstallationStepKind.Unknown; + + /// + /// Gets or sets the relative path of the target file to act upon in the delivered workspace or manifest. /// - public string Command { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TargetRelativePath { get; set; } /// - /// Gets or sets the arguments for the command. + /// Gets or sets the destination relative path when renaming or moving a file. + /// Only used when is . /// - public List Arguments { get; set; } = new(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DestinationRelativePath { get; set; } /// - /// Gets or sets the working directory for the command. + /// Gets or sets the arguments for executable steps. + /// Only used when is . /// - public string? WorkingDirectory { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Arguments { get; set; } /// /// Gets or sets a value indicating whether the step requires elevation. /// public bool RequiresElevation { get; set; } + + /// + /// Gets or sets an optional user-facing status message to display in notifications or progress. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StatusMessage { get; set; } + + /// + /// Gets or sets an optional unique key identifying this installation step for execution tracking across updates. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StepKey { get; set; } + + /// + /// Gets or sets a value indicating whether this step should only run once and be skipped on subsequent updates if already executed. + /// + public bool RunOnce { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs index 21b6cd066..3be9893e8 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs @@ -1,11 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; using GenHub.Core.Models.Validation; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content; @@ -15,14 +22,15 @@ namespace GenHub.Tests.Core.Features.Content; public class BaseContentProviderTests { /// - /// Verifies that PrepareContentAsync validates manifest before preparation. + /// Verifies that PrepareContentAsync validates manifest before preparation and executes post-install steps. /// /// A task representing the asynchronous operation. [Fact] - public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() + public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallStepsAsync() { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -40,7 +48,22 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() }) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -48,9 +71,101 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync() // Assert Assert.True(result.Success); validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once); + instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once); validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once); } + /// + /// Verifies that PrepareContentAsync fails and triggers rollback when post-install steps fail. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error")); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act + var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); + + // Assert + Assert.False(result.Success); + Assert.Contains("Post-install step execution error", result.FirstError); + Assert.True(provider.RollbackCalled); + } + + /// + /// Verifies that PrepareContentAsync triggers rollback when post-install steps are canceled. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync() + { + // Arrange + var validatorMock = new Mock(); + var instructionsMock = new Mock(); + var loggerMock = new Mock(); + var discovererMock = new Mock(); + var resolverMock = new Mock(); + var delivererMock = new Mock(); + + var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" }; + var validationResult = new ValidationResult(manifest.Id, new List()); + + validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(validationResult); + + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.PrepareContentAsync(manifest, "/tmp/test")); + + Assert.True(provider.RollbackCalled); + } + /// /// Verifies that PrepareContentAsync fails when manifest validation fails with errors. /// @@ -60,6 +175,7 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( { // Arrange var validatorMock = new Mock(); + var instructionsMock = new Mock(); var loggerMock = new Mock(); var discovererMock = new Mock(); var resolverMock = new Mock(); @@ -75,7 +191,13 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync( validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) .ReturnsAsync(validationResult); - var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object); + var provider = new TestContentProvider( + validatorMock.Object, + instructionsMock.Object, + loggerMock.Object, + discovererMock.Object, + resolverMock.Object, + delivererMock.Object); // Act var result = await provider.PrepareContentAsync(manifest, "/tmp/test"); @@ -94,13 +216,16 @@ private class TestContentProvider : BaseContentProvider private readonly IContentResolver _resolver; private readonly IContentDeliverer _deliverer; + public bool RollbackCalled { get; private set; } + public TestContentProvider( IContentValidator validator, + IInstallationInstructionsService instructionsService, ILogger logger, IContentDiscoverer discoverer, IContentResolver resolver, IContentDeliverer deliverer) - : base(validator, logger) + : base(validator, instructionsService, logger) { _discoverer = discoverer; _resolver = resolver; @@ -117,9 +242,19 @@ public TestContentProvider( protected override IContentDeliverer Deliverer => _deliverer; - public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default) + public override Task> GetValidatedContentAsync( + string contentId, + CancellationToken cancellationToken = default) { - var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" }; + var manifest = new ContentManifest + { + Id = ManifestId.Create(contentId), + Name = "Test Content", + Version = "1.0.0", + ContentType = ContentType.Map, + TargetGame = GameType.Generals, + }; + return Task.FromResult(OperationResult.CreateSuccess(manifest)); } @@ -128,5 +263,15 @@ protected override Task> PrepareContentInternal { return Task.FromResult(OperationResult.CreateSuccess(manifest)); } + + protected override Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + RollbackCalled = true; + return Task.CompletedTask; + } } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs index a50f687ff..8ecfe2931 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs @@ -46,12 +46,23 @@ public GitHubContentProviderTests() _validatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new ValidationResult("test", [])); + var instructionsMock = new Mock(); + instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _provider = new GitHubContentProvider( [_discovererMock.Object], [_resolverMock.Object], [_delivererMock.Object], _loggerMock.Object, - _validatorMock.Object); + _validatorMock.Object, + instructionsMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs new file mode 100644 index 000000000..2eb524a35 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs @@ -0,0 +1,1000 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public sealed class InstallationInstructionsServiceTests : IDisposable +{ + private readonly string _tempDirectory; + private readonly Mock _hashProviderMock; + private readonly Mock _notificationServiceMock; + private readonly Mock _userSettingsServiceMock; + private readonly UserSettings _userSettings; + private readonly InstallationInstructionsService _service; + + /// + /// Initializes a new instance of the class. + /// + public InstallationInstructionsServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDirectory); + + _hashProviderMock = new Mock(); + _notificationServiceMock = new Mock(); + _userSettingsServiceMock = new Mock(); + _userSettings = new UserSettings(); + + _userSettingsServiceMock.Setup(u => u.Get()).Returns(_userSettings); + _userSettingsServiceMock.Setup(u => u.Update(It.IsAny>())) + .Callback>(action => action(_userSettings)); + + _service = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + _userSettingsServiceMock.Object, + NullLogger.Instance); + } + + /// + /// Cleans up temporary resources after test execution. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + try + { + Directory.Delete(_tempDirectory, recursive: true); + } + catch + { + // Ignore cleanup error + } + } + } + + /// + /// Verifies that executing post-install steps succeeds when no steps are declared. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions(); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory); + + Assert.True(result.Success); + } + + /// + /// Verifies that executing installer steps from an untrusted provider fails even if manifest metadata claims to be trusted. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Malicious Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "malicious.exe", + }, + ], + }; + + // Manifest claims GeneralsOnline, but providerSource is untrusted + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + } + + /// + /// Verifies that mutating steps like RemoveFile and RenameFile fail and do not modify files on disk when provider is untrusted. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution() + { + var importantFilePath = Path.Combine(_tempDirectory, "important.dat"); + var sourceFilePath = Path.Combine(_tempDirectory, "source.dat"); + var destFilePath = Path.Combine(_tempDirectory, "dest.dat"); + + await File.WriteAllTextAsync(importantFilePath, "important content"); + await File.WriteAllTextAsync(sourceFilePath, "source content"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Delete Something", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "important.dat", + }, + new InstallationStep + { + Name = "Rename Something", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.dat", + DestinationRelativePath = "dest.dat", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source"); + + Assert.False(result.Success); + Assert.Contains("not authorized to execute installation steps", result.FirstError); + Assert.True(File.Exists(importantFilePath)); + Assert.True(File.Exists(sourceFilePath)); + Assert.False(File.Exists(destFilePath)); + } + + /// + /// Verifies that paths attempting directory traversal are rejected. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Traverse Path", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = @"../../outside.exe", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that installer executables not declared in the manifest files list fail. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = []; // Empty files list - installer not declared + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Undeclared Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("not declared in manifest files", result.FirstError); + } + + /// + /// Verifies that hash mismatch during installer integrity check fails execution. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution() + { + var targetFile = "installer.exe"; + var fullPath = Path.Combine(_tempDirectory, targetFile); + File.WriteAllText(fullPath, "binary content"); + + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync("actual_hash_value"); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = targetFile, + Hash = "expected_different_hash", + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Run Corrupted Installer", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = targetFile, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("Integrity verification failed", result.FirstError); + } + + /// + /// Verifies that remove file steps successfully delete the target file. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile() + { + var fileToRemove = "temp_cache.tmp"; + var fullPath = Path.Combine(_tempDirectory, fileToRemove); + File.WriteAllText(fullPath, "temporary content"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Cache", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = fileToRemove, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.False(File.Exists(fullPath)); + } + + /// + /// Verifies that rename file steps successfully move target files. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile() + { + var sourceFile = "source.txt"; + var destFile = Path.Combine("subfolder", "dest.txt"); + var sourceFullPath = Path.Combine(_tempDirectory, sourceFile); + var destFullPath = Path.Combine(_tempDirectory, destFile); + + File.WriteAllText(sourceFullPath, "hello world"); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename File", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = sourceFile, + DestinationRelativePath = destFile, + StepKey = "test_rename_step", + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.False(File.Exists(sourceFullPath)); + Assert.True(File.Exists(destFullPath)); + Assert.Equal("hello world", File.ReadAllText(destFullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step")); + } + + /// + /// Verifies that verified installer execution runs and dispatches user notifications. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification() + { + var scriptName = OperatingSystem.IsWindows() ? "test_installer.exe" : "test_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "test_installer_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey)); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + _notificationServiceMock.Verify( + n => n.ShowSuccess( + "Installation Step Completed", + It.Is(msg => msg.Contains(GeneralsOnlineConstants.EacStepName)), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that run-once steps already recorded in user settings are skipped. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution() + { + var scriptName = "installer.bat"; + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile { RelativePath = scriptName }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + + // Notification should NOT be shown for skipped step + _notificationServiceMock.Verify( + n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Verifies that forcing execution re-runs run-once steps even if recorded in settings. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded() + { + var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.exe" : "test_force_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "test_force_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }; + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [], + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }; + + // Mark as already executed in settings + _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey); + + // Force execution + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, force: true); + + Assert.True(result.Success); + _notificationServiceMock.Verify( + n => n.ShowInfo( + GeneralsOnlineConstants.EacStepName, + GeneralsOnlineConstants.EacStatusMessage, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that unknown installation step kinds return failure. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Unknown Step", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("Unsupported installation step kind", result.FirstError); + } + + /// + /// Verifies that elevated steps fail with an unsupported result on non-Windows platforms. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFailure() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var scriptName = "elevated_script.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + const string expectedHash = "elevated_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Elevated Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + RequiresElevation = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("requires administrator elevation, which is only supported on Windows", result.FirstError); + } + + /// + /// Verifies that remove file steps reject paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RemoveFile_PathTraversalTarget_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Remove Escape", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject source paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_SourcePathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Source Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "../../outside.tmp", + DestinationRelativePath = "dest.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that rename file steps reject destination paths that escape the working directory. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RenameFile_DestinationPathTraversal_FailsExecution() + { + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Rename Destination Escape", + Kind = InstallationStepKind.RenameFile, + TargetRelativePath = "source.tmp", + DestinationRelativePath = "../../outside.tmp", + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("escapes the working directory", result.FirstError); + } + + /// + /// Verifies that cancellation token terminates the running process and throws OperationCanceledException. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows() + { + var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.exe" : "sleep_installer.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe"); + File.Copy(systemCmd, fullPath, overwrite: true); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nsleep 30\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "sleep_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Long Running Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [], + }, + ], + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(200)); + + await Assert.ThrowsAnyAsync(() => + _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline, + cancellationToken: cts.Token)); + } + + /// + /// Verifies that when a precondition is fulfilled, execution is skipped and the step key is recorded. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_PreconditionFulfilled_SkipsExecutionAndRecordsStepKey() + { + var preconditionMock = new Mock(); + preconditionMock.Setup(p => p.CanHandle(It.IsAny(), It.IsAny())).Returns(true); + preconditionMock.Setup(p => p.IsAlreadyFulfilled(It.IsAny(), It.IsAny())).Returns(true); + + var serviceWithPrecondition = new InstallationInstructionsService( + _hashProviderMock.Object, + _notificationServiceMock.Object, + _userSettingsServiceMock.Object, + [preconditionMock.Object], + NullLogger.Instance); + + const string stepKey = "test:precondition:step"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Preconditioned Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "nonexistent.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await serviceWithPrecondition.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.True(result.Success); + Assert.True(_userSettings.IsInstallationStepExecuted(stepKey)); + } + + /// + /// Verifies that verification fails when a step target file has no declared hash in the manifest. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NoDeclaredHash_FailsVerification() + { + var scriptName = "installer_nohash.exe"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + File.WriteAllText(fullPath, "binary content"); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = string.Empty, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "No Hash Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("has no declared hash", result.FirstError); + } + + /// + /// Verifies that an installer process exiting with a non-zero exit code produces an execution failure. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution() + { + var scriptName = OperatingSystem.IsWindows() ? "exit_error.cmd" : "exit_error.sh"; + var fullPath = Path.Combine(_tempDirectory, scriptName); + + if (OperatingSystem.IsWindows()) + { + File.WriteAllText(fullPath, "exit /b 42\r\n"); + } + else + { + File.WriteAllText(fullPath, "#!/bin/sh\nexit 42\n"); + File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + const string expectedHash = "exit_error_hash"; + _hashProviderMock + .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny())) + .ReturnsAsync(expectedHash); + + var manifest = CreateBaseManifest(); + manifest.Files = + [ + new ManifestFile + { + RelativePath = scriptName, + Hash = expectedHash, + }, + ]; + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Failing Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = scriptName, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.Contains("failed with exit code", result.FirstError); + } + + /// + /// Verifies that a successful RunOnce step persists its key immediately even if a subsequent step fails. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStep_PersistsKeyImmediatelyEvenIfLaterStepFails() + { + var successFile = "success.tmp"; + var fullPath = Path.Combine(_tempDirectory, successFile); + await File.WriteAllTextAsync(fullPath, "temporary"); + + const string step1Key = "step:runonce:first"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1 Remove", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = successFile, + StepKey = step1Key, + RunOnce = true, + }, + new InstallationStep + { + Name = "Step 2 Unknown Kind", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.False(File.Exists(fullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted(step1Key)); + _userSettingsServiceMock.Verify(u => u.SaveAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that an already-executed RunOnce step is skipped without failing provider authorization. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceAlreadyExecuted_DoesNotFailAuthorizationForUntrustedProvider() + { + const string stepKey = "step:untrusted:runonce"; + _userSettings.RecordInstallationStepExecuted(stepKey); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Already Executed Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "installer.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: "untrusted_source"); + + Assert.True(result.Success); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Test Manifest", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs new file mode 100644 index 000000000..7f1913983 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for . +/// +public sealed class EasyAntiCheatPreconditionTests +{ + private readonly EasyAntiCheatPrecondition _precondition = new(NullLogger.Instance); + + /// + /// Verifies that CanHandle returns false when step or manifest is null. + /// + [Fact] + public void CanHandle_NullStepOrManifest_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(null!, manifest)); + Assert.False(_precondition.CanHandle(step, null!)); + } + + /// + /// Verifies that CanHandle returns false when step kind is not RunVerifiedInstaller. + /// + [Fact] + public void CanHandle_NonInstallerKind_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Remove File Step", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when publisher type is not GeneralsOnline. + /// + [Fact] + public void CanHandle_NonGeneralsOnlinePublisher_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + PublisherType = "OtherPublisher", + }; + + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when executable name does not match EAC setup executable. + /// + [Fact] + public void CanHandle_NonEacExecutable_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Other Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "other_installer.exe", + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that IsAlreadyFulfilled returns false on non-Windows platforms. + /// + [Fact] + public void IsAlreadyFulfilled_NonWindows_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.IsAlreadyFulfilled(step, manifest)); + } + + /// + /// Verifies that CanHandle behavior matches operating system requirements. + /// + [Fact] + public void CanHandle_ValidStep_MatchesOperatingSystem() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + var result = _precondition.CanHandle(step, manifest); + Assert.Equal(OperatingSystem.IsWindows(), result); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Generals Online", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + + private static InstallationStep CreateEacStep() => new() + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = ["install", GeneralsOnlineConstants.EacProductId], + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs index 4e5d7c096..ec5ff6a19 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs @@ -561,6 +561,74 @@ await Assert.ThrowsAsync( Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); } + /// + /// Verifies that DeliverContentAsync passes the declared expected hash to IDownloadService.DownloadFileAsync. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_WithDeclaredHash_PassesExpectedHashToDownloadServiceAsync() + { + // Arrange + const string expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var zipPath = Path.Combine(_tempDir, "test_hash.zip"); + CreateTestZip(zipPath); + + string? capturedExpectedHash = null; + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => + { + capturedExpectedHash = hash; + File.Copy(zipPath, path, true); + }) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + Hash = expectedHash, + }, + ], + InstallationInstructions = new InstallationInstructions + { + DownloadHash = expectedHash, + }, + }; + + var targetDir = Path.Combine(_tempDir, "hash_delivery"); + Directory.CreateDirectory(targetDir); + + // Act + var result = await _deliverer.DeliverContentAsync(manifest, targetDir); + + // Assert + Assert.True(result.Success); + Assert.Equal(expectedHash, capturedExpectedHash); + } + private static void CreateTestZip(string zipPath) { using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs index a016b37fc..db6aaaff6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.GeneralsOnline; using GenHub.Core.Models.Providers; using GenHub.Features.Content.Services.GeneralsOnline; using Microsoft.Extensions.Logging.Abstractions; @@ -91,4 +92,34 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectlyAsync() var item = result.Data.First(); Assert.Equal("111825_QFE2", item.Version); } + + /// + /// Tests that ParseAsync correctly populates the SHA256 hash when present in the API response. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_WithSha256_PopulatesSha256OnReleaseAsync() + { + // Arrange + const string expectedSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var json = $@"{{ + ""version"": ""111825_QFE2"", + ""download_url"": ""https://example.com/download.zip"", + ""size"": 123456, + ""sha256"": ""{expectedSha256}"", + ""release_notes"": ""Fixes stuff"" + }}"; + + var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data.First(); + var release = item.GetData(); + Assert.NotNull(release); + Assert.Equal(expectedSha256, release.Sha256); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs index 04e9b80a9..443824a51 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -129,6 +129,115 @@ public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSix ignoreCase: true); } + /// + /// Verifies that EAC portable layout configures a post-install step to run the verified EAC setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_ConfiguresEacPostInstallStepAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var postSteps = gameClient.InstallationInstructions.PostInstallSteps; + var eacStep = Assert.Single(postSteps); + + Assert.Equal(GeneralsOnlineConstants.EacStepName, eacStep.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, eacStep.Kind); + Assert.Equal(GameClientConstants.GeneralsOnlineEacSetupExecutable, eacStep.TargetRelativePath); + Assert.True(eacStep.RequiresElevation); + Assert.True(eacStep.RunOnce); + Assert.Equal(GeneralsOnlineConstants.EacStepKey, eacStep.StepKey); + Assert.Equal(GeneralsOnlineConstants.EacStatusMessage, eacStep.StatusMessage); + Assert.NotNull(eacStep.Arguments); + Assert.Equal( + [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + eacStep.Arguments); + } + + /// + /// Verifies that Pre-EAC portable layout does not configure an EAC post-install step when setup executable is absent. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_DoesNotConfigureEacPostInstallStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + + /// + /// Verifies that an inherited EAC step is not duplicated when EAC portable layout already contains the setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutablePresent_DoesNotDuplicateEacStepAsync() + { + WriteEacPortableLayout(); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacSteps = gameClient.InstallationInstructions.PostInstallSteps.Where(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.Single(eacSteps); + } + + /// + /// Verifies that an inherited EAC step is dropped when the setup executable is absent in extracted content. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutableAbsent_DropsEacStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + /// public void Dispose() { @@ -170,7 +279,7 @@ private void WriteFile(string relativePath) File.WriteAllText(fullPath, relativePath); } - private async Task CreateGameClientManifestAsync() + private async Task CreateGameClientManifestAsync(ContentManifest? originalManifest = null) { var providerLoader = new Mock(); var factory = new GeneralsOnlineManifestFactory( @@ -178,7 +287,7 @@ private async Task CreateGameClientManifestAsync() providerLoader.Object); var manifests = await factory.CreateManifestsFromExtractedContentAsync( - CreateOriginalManifest(), + originalManifest ?? CreateOriginalManifest(), _extractedDirectory); return manifests.Single(manifest => manifest.ContentType == ContentType.GameClient); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs index 68a1e4434..200f82338 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs @@ -401,4 +401,33 @@ public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependen var resolvedClientDep = resolvedDeps.First(d => d.DependencyType == ContentType.GameClient); Assert.Equal(expectedClientId.Value, resolvedClientDep.Id.Value); } + + /// + /// Verifies that CreateManifests propagates Sha256 to file hash and installation instructions download hash. + /// + [Fact] + public void CreateManifests_WithSha256_SetsFileHashAndDownloadHash() + { + // Arrange + const string expectedHash = "abc123hash"; + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip", + PortableSize = 1048576, + Sha256 = expectedHash, + Changelog = "https://example.com/changelog", + }; + + // Act + var manifests = _factory.CreateManifests(release); + + // Assert + var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + Assert.NotNull(gameClient); + Assert.Equal(expectedHash, gameClient.InstallationInstructions?.DownloadHash); + var zipFile = Assert.Single(gameClient.Files); + Assert.Equal(expectedHash, zipFile.Hash); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs index 2b645c6e5..ace7c1ac6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs @@ -30,6 +30,7 @@ public class SuperHackersProviderTests private readonly Mock _resolverMock; private readonly Mock _delivererMock; private readonly Mock _validatorMock; + private readonly Mock _instructionsServiceMock; private readonly SuperHackersProvider _provider; /// @@ -42,6 +43,7 @@ public SuperHackersProviderTests() _resolverMock = new Mock(); _delivererMock = new Mock(); _validatorMock = new Mock(); + _instructionsServiceMock = new Mock(); _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId); _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer); @@ -49,13 +51,23 @@ public SuperHackersProviderTests() _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new ValidationResult("test", [])); + _instructionsServiceMock.Setup(s => s.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _provider = new SuperHackersProvider( _providerDefinitionLoaderMock.Object, _gitHubApiClientMock.Object, [_resolverMock.Object], [_delivererMock.Object], _validatorMock.Object, - NullLogger.Instance); + NullLogger.Instance, + _instructionsServiceMock.Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs index 836d0ba16..094306d9e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileLauncherViewModelTests.cs @@ -427,7 +427,8 @@ private static SuperHackersProvider CreateSuperHackersProvider() [resolverMock.Object], [delivererMock.Object], new Mock().Object, - NullLogger.Instance); + NullLogger.Instance, + new Mock().Object); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs index aad8be697..eafa0df4c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Manifest/ContentManifestBuilderTests.cs @@ -208,6 +208,60 @@ public void WithInstallationInstructions_SetsWorkspaceStrategy() Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); } + /// + /// Tests that WithInstallationInstructions sets the full installation instructions object. + /// + [Fact] + public void WithInstallationInstructions_SetsCompleteObject() + { + var instructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceStrategy.FullCopy, + DownloadHash = "abc123hash", + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "setup.exe", + }, + ], + }; + + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .WithInstallationInstructions(instructions) + .Build(); + + Assert.NotNull(result.InstallationInstructions); + Assert.Equal(WorkspaceStrategy.FullCopy, result.InstallationInstructions.WorkspaceStrategy); + Assert.Equal("abc123hash", result.InstallationInstructions.DownloadHash); + Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("Step 1", result.InstallationInstructions.PostInstallSteps[0].Name); + } + + /// + /// Tests that AddPostInstallStep adds a structured installation step. + /// + [Fact] + public void AddPostInstallStep_AddsStepCorrectly() + { + var result = _builder + .WithBasicInfo("Test Publisher", "Test Name", "1") + .AddPostInstallStep("EAC Setup", InstallationStepKind.RunVerifiedInstaller, "EasyAntiCheat_EOS_Setup.exe", ["install", "12345"], requiresElevation: true, statusMessage: "Installing AntiCheat") + .Build(); + + Assert.NotNull(result.InstallationInstructions); + var step = Assert.Single(result.InstallationInstructions.PostInstallSteps); + Assert.Equal("EAC Setup", step.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, step.Kind); + Assert.Equal("EasyAntiCheat_EOS_Setup.exe", step.TargetRelativePath); + Assert.True(step.RequiresElevation); + Assert.Equal("Installing AntiCheat", step.StatusMessage); + Assert.Equal(["install", "12345"], step.Arguments); + } + /// /// Tests that Build returns a valid manifest with minimal configuration. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs index 41e389ba1..9bdd35e8b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/PathHelperTests.cs @@ -114,6 +114,40 @@ public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink() } } + /// + /// Rejects a candidate that leaves the base directory through an intermediate symbolic link + /// when the target file on the outside destination already exists on disk. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateLeavingThroughASymbolicLink_WhenOutsideTargetFileExists() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "installer.exe"); + File.WriteAllText(outsideFile, "payload"); + + if (!TryCreateDirectorySymbolicLink(Path.Combine(baseDirectory, "link"), outside)) + { + return; + } + + var candidate = Path.Combine(baseDirectory, "link", "installer.exe"); + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, candidate)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + /// /// Accepts a candidate beneath a symbolic link that stays inside the base directory, so /// following links tightens the check without refusing content a link merely reorganizes. @@ -144,6 +178,51 @@ public void IsPathWithinDirectory_AcceptsCandidateBehindASymbolicLinkThatStaysIn } } + /// + /// Rejects a candidate that is a direct file symbolic link pointing to a file outside the base directory. + /// + [Fact] + public void IsPathWithinDirectory_RejectsCandidateThatIsDirectFileSymbolicLink_PointingOutside() + { + var root = CreateWorkingDirectory(); + + try + { + var baseDirectory = Path.Combine(root, "extract"); + var outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(baseDirectory); + Directory.CreateDirectory(outside); + + var outsideFile = Path.Combine(outside, "secret.dat"); + File.WriteAllText(outsideFile, "secret"); + + var linkFile = Path.Combine(baseDirectory, "link_file.dat"); + if (!TryCreateFileSymbolicLink(linkFile, outsideFile)) + { + return; + } + + Assert.False(PathHelper.IsPathWithinDirectory(baseDirectory, linkFile)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Verifies that NormalizeRelativePath standardizes path separators. + /// + [Fact] + public void NormalizeRelativePath_StandardizesSeparators() + { + var input = @"folder\subfolder/file.exe"; + var normalized = PathHelper.NormalizeRelativePath(input); + + var expected = Path.Combine("folder", "subfolder", "file.exe"); + Assert.Equal(expected, normalized); + } + private static string CreateWorkingDirectory() { var root = Path.Combine(Path.GetTempPath(), "GenHubContainmentLinks", Guid.NewGuid().ToString("N")); @@ -169,4 +248,26 @@ private static bool TryCreateDirectorySymbolicLink(string linkPath, string targe return false; } } + + private static bool TryCreateFileSymbolicLink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs index 3ed1922ae..79ae5fcde 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostProvider.cs @@ -25,6 +25,7 @@ namespace GenHub.Features.Content.Services.CommunityOutpost; /// Available content resolvers. /// Available content deliverers. /// The content validator. +/// The installation instructions service. /// The logger. public class CommunityOutpostProvider( IProviderDefinitionLoader providerDefinitionLoader, @@ -32,11 +33,10 @@ public class CommunityOutpostProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { - private readonly IProviderDefinitionLoader _providerDefinitionLoader = providerDefinitionLoader; - private readonly IContentDiscoverer _discoverer = discoverers.FirstOrDefault(d => d.SourceName.Contains(CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException("No Community Outpost discoverer found"); @@ -127,7 +127,7 @@ public override async Task> GetValidatedContent } // Try to get from the loader (it should already be loaded at startup) - _cachedProviderDefinition = _providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); + _cachedProviderDefinition = providerDefinitionLoader.GetProvider(CommunityOutpostConstants.PublisherId); if (_cachedProviderDefinition == null) { diff --git a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs index 42ee8b423..73ac811e1 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDeliverers/FileSystemDeliverer.cs @@ -122,7 +122,8 @@ public async Task> DeliverContentAsync( packageManifest.Publisher?.Name ?? string.Empty, packageManifest.Publisher?.Website ?? string.Empty, packageManifest.Publisher?.SupportUrl ?? string.Empty, - packageManifest.Publisher?.ContactEmail ?? string.Empty) + packageManifest.Publisher?.ContactEmail ?? string.Empty, + packageManifest.Publisher?.PublisherType ?? string.Empty) .WithMetadata( packageManifest.Metadata?.Description ?? string.Empty, packageManifest.Metadata?.Tags, @@ -173,7 +174,7 @@ await manifestBuilder.AddContentAddressableFileAsync( // Add installation instructions if present if (packageManifest.InstallationInstructions != null) { - manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions.WorkspaceStrategy); + manifestBuilder.WithInstallationInstructions(packageManifest.InstallationInstructions); } var deliveredManifest = manifestBuilder.Build(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs index dea3d0047..e071eb54b 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/AODMapsContentProvider.cs @@ -21,7 +21,9 @@ public class AODMapsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _aodMapsDiscoverer = discoverers.FirstOrDefault(d => string.Equals(d.SourceName, AODMapsConstants.DiscovererSourceName, StringComparison.OrdinalIgnoreCase)) diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs index 0262f9655..be26d3495 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/BaseContentProvider.cs @@ -18,13 +18,27 @@ namespace GenHub.Features.Content.Services.ContentProviders; /// /// Base class for content providers with common pipeline orchestration logic. /// -public abstract class BaseContentProvider( - IContentValidator contentValidator, - ILogger logger -) : IContentProvider +public abstract class BaseContentProvider : IContentProvider { - private readonly ILogger logger = logger ?? throw new ArgumentNullException(nameof(logger)); - private readonly IContentValidator _contentValidator = contentValidator ?? throw new ArgumentNullException(nameof(contentValidator)); + private readonly IContentValidator _contentValidator; + private readonly IInstallationInstructionsService _installationInstructionsService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The content validator. + /// The installation instructions service. + /// The logger. + protected BaseContentProvider( + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, + ILogger logger) + { + _contentValidator = contentValidator; + _installationInstructionsService = installationInstructionsService; + _logger = logger; + } /// public abstract string SourceName { get; } @@ -89,7 +103,7 @@ public virtual async Task>> Sea Logger.LogWarning( "Resolution failed for {ContentName}: {Error}", discovered.Name, - resolutionResult.FirstError ?? "Unknown error"); + resolutionResult.FirstError); } } else @@ -101,12 +115,7 @@ public virtual async Task>> Sea return OperationResult>.CreateSuccess(resolvedResults); } - /// - /// Gets the manifest for the specified content ID. - /// - /// The content identifier. - /// A token to cancel the operation. - /// A result containing the game manifest. + /// public abstract Task> GetValidatedContentAsync( string contentId, CancellationToken cancellationToken = default); @@ -149,50 +158,99 @@ public virtual async Task> PrepareContentAsync( // Delegate to implementation-specific preparation var result = await PrepareContentInternalAsync(manifest, workingDirectory, progress, cancellationToken); - if (result.Success) + if (!result.Success) { - // Final validation of prepared content - progress?.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - CurrentOperation = "Validating prepared content...", - }); + return result; + } - // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress - IProgress? validationProgress = null; - if (progress != null) - { - validationProgress = new Progress(vp => - { - // Map validation progress to content acquisition progress for UI display - progress.Report(new ContentAcquisitionProgress - { - Phase = ContentAcquisitionPhase.ValidatingFiles, - ProgressPercentage = vp.PercentComplete, - CurrentOperation = vp.CurrentFile ?? "Validating files", - FilesProcessed = vp.Processed, - TotalFiles = vp.Total, - }); - }); - } + if (result.Data == null) + { + Logger.LogError("Content preparation returned success without manifest data for {ManifestId}", manifest.Id); + return OperationResult.CreateFailure($"Content preparation returned no manifest data for {manifest.Id}."); + } - var fullResult = await ContentValidator.ValidateAllAsync( + try + { + // Execute post-installation steps if declared on the delivered manifest + var stepExecutionResult = await _installationInstructionsService.ExecutePostInstallStepsAsync( + result.Data, workingDirectory, - result.Data!, - validationProgress, + providerSource: SourceName, + progress: progress, cancellationToken: cancellationToken); - if (!fullResult.IsValid) + if (!stepExecutionResult.Success) { - // Log as warning only - content may have been moved to CAS already - // CAS storage validates content hash on store, so this is informational - Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); - foreach (var issue in fullResult.Issues.Take(5)) - { - Logger.LogDebug("Validation issue: {Message}", issue.Message); - } + Logger.LogError("Post-installation steps failed for manifest {ManifestId}: {Error}", manifest.Id, stepExecutionResult.FirstError); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure(stepExecutionResult.Errors); } } + catch (OperationCanceledException) + { + Logger.LogInformation("Post-installation execution was canceled for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error executing post-installation steps for manifest {ManifestId}; rolling back prepared content", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Post-installation execution failed: {ex.Message}"); + } + + // Final validation of prepared content + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.ValidatingFiles, + CurrentOperation = "Validating prepared content...", + }); + + // Forward provider progress into validation by adapting ValidationProgress -> ContentAcquisitionProgress + IProgress? validationProgress = null; + if (progress != null) + { + validationProgress = new Progress(vp => + { + // Map validation progress to content acquisition progress for UI display + progress.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.ValidatingFiles, + ProgressPercentage = vp.PercentComplete, + CurrentOperation = vp.CurrentFile ?? "Validating files", + FilesProcessed = vp.Processed, + TotalFiles = vp.Total, + }); + }); + } + + var fullResult = await ContentValidator.ValidateAllAsync( + workingDirectory, + result.Data, + validationProgress, + cancellationToken: cancellationToken); + + if (!fullResult.IsValid) + { + Logger.LogWarning("Content validation found {IssueCount} issues for {ManifestId}", fullResult.Issues.Count, manifest.Id); + } + + try + { + await OnContentPreparationCompletedAsync(manifest, result.Data, workingDirectory, cancellationToken); + } + catch (OperationCanceledException) + { + Logger.LogInformation("Content preparation completion hook was canceled for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Content preparation completion hook failed for manifest {ManifestId}; rolling back", manifest.Id); + await SafeRollbackPreparedContentAsync(manifest, result.Data, workingDirectory); + return OperationResult.CreateFailure($"Content preparation completion hook failed: {ex.Message}"); + } return result; } @@ -208,16 +266,55 @@ public virtual async Task> PrepareContentAsync( } } + /// + /// Rolls back prepared content and registered manifests when post-preparation steps fail. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel rollback operations. + /// A task representing the asynchronous operation. + protected virtual Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// Executes cleanup or finalization when content preparation and validation succeed. + /// + /// The original requested manifest. + /// The prepared manifest returned by PrepareContentInternalAsync. + /// The working directory where content was prepared. + /// A token to cancel finalization operations. + /// A task representing the asynchronous operation. + protected virtual Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + /// /// Gets the logger for this provider. /// - protected ILogger Logger => logger; + protected ILogger Logger => _logger; /// /// Gets the content validator for manifest validation. /// protected IContentValidator ContentValidator => _contentValidator; + /// + /// Gets the installation instructions service for post-install execution. + /// + protected IInstallationInstructionsService? InstallationInstructionsService => _installationInstructionsService; + /// /// Gets the discoverer for this provider. /// @@ -315,4 +412,19 @@ private ContentSearchResult CreateResolvedSearchResult(ContentSearchResult disco resolved.SetData(manifest); return resolved; } + + private async Task SafeRollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory) + { + try + { + await RollbackPreparedContentAsync(originalManifest, preparedManifest, workingDirectory, CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Rollback failed during error recovery for manifest {ManifestId}", originalManifest.Id); + } + } } diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs index 4c166bb66..8017522e7 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/CNCLabsContentProvider.cs @@ -21,8 +21,9 @@ public class CNCLabsContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _cncLabsDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.CNCLabsDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("CNC Labs discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs index 16300c566..cbbac85a5 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/LocalFileSystemContentProvider.cs @@ -24,8 +24,9 @@ public class LocalFileSystemContentProvider( IEnumerable deliverers, ILogger logger, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IConfigurationProviderService configurationProvider) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _fileSystemDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.FileSystemDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new InvalidOperationException("No FileSystem discoverer found"); diff --git a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs index 8595f0d79..089ff48d6 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentProviders/ModDBContentProvider.cs @@ -21,8 +21,9 @@ public class ModDBContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentDiscoverer _moddbDiscoverer = discoverers.FirstOrDefault(d => d.SourceName?.Equals(ContentSourceNames.ModDBDiscoverer, StringComparison.OrdinalIgnoreCase) == true) ?? throw new ArgumentException("ModDB discoverer not found", nameof(discoverers)); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs new file mode 100644 index 000000000..f538f8c05 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/EasyAntiCheatPrecondition.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Runtime.Versioning; +using System.Security; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Features.Content.Services.GeneralsOnline; + +/// +/// Precondition that checks whether Easy Anti-Cheat EOS product ID is already registered in the Windows registry. +/// +/// Optional logger instance for diagnostics. +public class EasyAntiCheatPrecondition(ILogger? logger = null) : IInstallationStepPrecondition +{ + /// + public bool CanHandle(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows() || step == null || manifest == null) + { + return false; + } + + if (step.Kind != InstallationStepKind.RunVerifiedInstaller) + { + return false; + } + + var isGeneralsOnline = string.Equals( + manifest.Publisher?.PublisherType, + PublisherTypeConstants.GeneralsOnline, + StringComparison.OrdinalIgnoreCase); + + if (!isGeneralsOnline) + { + return false; + } + + var fileName = Path.GetFileName(step.TargetRelativePath ?? string.Empty); + return string.Equals(fileName, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase); + } + + /// + public bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest) + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + return IsProductRegisteredOnWindows(step); + } + + [SupportedOSPlatform("windows")] + private bool IsProductRegisteredOnWindows(InstallationStep step) + { + try + { + var productId = (step.Arguments is { Count: > 1 } && !string.IsNullOrWhiteSpace(step.Arguments[1])) + ? step.Arguments[1] + : GeneralsOnlineConstants.EacProductId; + + if (string.IsNullOrWhiteSpace(productId)) + { + return false; + } + + var subKeyPath = $@"SOFTWARE\EasyAntiCheat_EOS\{productId}"; + + using var baseKey32 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32); + using var key32 = baseKey32.OpenSubKey(subKeyPath); + if (key32 != null) + { + return true; + } + + using var baseKey64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var key64 = baseKey64.OpenSubKey(subKeyPath); + if (key64 != null) + { + return true; + } + } + catch (SecurityException ex) + { + logger?.LogWarning(ex, "Insufficient permissions to inspect Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.LogWarning(ex, "Access denied when inspecting Easy Anti-Cheat registry keys for step '{StepName}'", step.Name); + return false; + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Error while checking Easy Anti-Cheat registry registration for step '{StepName}'", step.Name); + return false; + } + + return false; + } +} diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs index 53e9cb95e..c04d78760 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineDeliverer.cs @@ -272,11 +272,20 @@ private static void CleanupTempArtifacts(string? zipPath, string? extractPath, I CurrentFile = zipFile.RelativePath, }); - logger.LogDebug("Downloading ZIP from {Url} to {Path}", zipFile.DownloadUrl, zipPath); + var expectedHash = !string.IsNullOrWhiteSpace(zipFile.Hash) + ? zipFile.Hash + : packageManifest.InstallationInstructions?.DownloadHash; + + if (string.IsNullOrWhiteSpace(expectedHash)) + { + expectedHash = null; + } + + logger.LogDebug("Downloading ZIP from {Url} to {Path} (expected hash: {Hash})", zipFile.DownloadUrl, zipPath, expectedHash); var downloadResult = await downloadService.DownloadFileAsync( new Uri(zipFile.DownloadUrl!), zipPath, - expectedHash: null, + expectedHash: expectedHash, progress: null, cancellationToken); diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs index d2ab6ed83..91f2ed956 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParser.cs @@ -148,6 +148,7 @@ private static GeneralsOnlineRelease CreateReleaseFromApiResponse(GeneralsOnline ReleaseDate = versionDate, PortableUrl = apiResponse.DownloadUrl, PortableSize = apiResponse.Size, + Sha256 = apiResponse.Sha256, Changelog = apiResponse.ReleaseNotes ?? $"Generals Online {apiResponse.Version}", }; } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 59a2eb756..712b76997 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -25,6 +25,21 @@ public class GeneralsOnlineManifestFactory( ILogger logger, IProviderDefinitionLoader providerLoader) : IPublisherManifestFactory { + /// + /// File info extracted from archive for manifest generation. + /// + /// The relative path within archive. + /// The file info. + /// The SHA-256 hash. + /// Whether this is a map file. + /// Whether this is a game data file. + private readonly record struct ExtractedFileInfo( + string RelativePath, + FileInfo FileInfo, + string Hash, + bool IsMap, + bool IsGameData); + /// public string PublisherId => PublisherTypeConstants.GeneralsOnline; @@ -96,10 +111,29 @@ public ContentManifest CreateVariantManifest( DownloadUrl = release.PortableUrl, Size = release.PortableSize ?? 0, // Use 0 when size is unknown SourceType = ContentSourceType.RemoteDownload, - Hash = string.Empty, + Hash = release.Sha256 ?? string.Empty, }, ], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = release.Sha256, + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }, + ], + }, }; } @@ -323,6 +357,7 @@ private ContentManifest CreateGameDataPatchManifest(GeneralsOnlineRelease releas // Files will be populated during extraction Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }; } @@ -378,18 +413,19 @@ private ContentManifest CreateQuickMatchMapPackManifest(GeneralsOnlineRelease re // MapPack requires Zero Hour installation GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }; } /// - /// Creates all variant manifests (60Hz, MapPack, and GameData Patch) from the original manifest. - /// This is called AFTER extraction - we use the original manifest's metadata to create variants. + /// Creates variant manifests (60Hz, QuickMatch MapPack, and GeneralsOnlineGameData data patch) from an original manifest. + /// This is used after downloading and extracting the portable ZIP. /// - /// The manifest from the Resolver (contains version, publisher info, etc.). - /// List of variant manifests ready for file hash population. + /// The original manifest (can be 60Hz or generic). + /// List of variant manifests with basic information populated. private List CreateVariantManifestsFromOriginal(ContentManifest originalManifest) { - var manifests = new List(); + List manifests = []; var version = originalManifest.Version ?? GeneralsOnlineConstants.UnknownVersion; var userVersion = ParseVersionForManifestId(version); @@ -440,6 +476,10 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesFor60Hz(userVersion), + InstallationInstructions = originalManifest.InstallationInstructions ?? new InstallationInstructions + { + WorkspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy, + }, }); // Create QuickMatch MapPack @@ -469,6 +509,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest [ GeneralsOnlineDependencyBuilder.CreateZeroHourDependencyForGeneralsOnline(), ], + InstallationInstructions = new InstallationInstructions(), }); // Create GeneralsOnlineGameData data patch @@ -495,6 +536,7 @@ private List CreateVariantManifestsFromOriginal(ContentManifest }, Files = [], Dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(userVersion), + InstallationInstructions = new InstallationInstructions(), }); return manifests; @@ -520,10 +562,63 @@ private async Task> UpdateManifestsWithExtractedFiles( cancellationToken.ThrowIfCancellationRequested(); + var filesWithHashes = await ScanExtractedFilesAsync(extractPath, cancellationToken); + var updatedManifests = new List(); + + foreach (var manifest in manifests) + { + var manifestFiles = BuildManifestFilesForManifest(manifest, filesWithHashes); + + if (manifestFiles.Count == 0) + { + if (manifest.ContentType is ContentType.MapPack or ContentType.Patch) + { + logger.LogInformation( + "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", + manifest.ContentType, + manifest.Name); + continue; + } + + logger.LogError( + "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", + manifest.Name, + manifest.ContentType, + extractPath); + throw new InvalidDataException( + $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); + } + + var instructions = BuildInstallationInstructions(manifest, filesWithHashes); + + updatedManifests.Add(new ContentManifest + { + Id = manifest.Id, + Name = manifest.Name, + Version = manifest.Version, + ContentType = manifest.ContentType, + TargetGame = manifest.TargetGame, + Publisher = manifest.Publisher, + Metadata = manifest.Metadata, + Files = manifestFiles, + Dependencies = manifest.Dependencies, + InstallationInstructions = instructions, + }); + } + + ReconcileMissingMapPackDependencies(updatedManifests); + + return updatedManifests; + } + + private async Task> ScanExtractedFilesAsync( + string extractPath, + CancellationToken cancellationToken) + { var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); - List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap, bool IsGameData)> filesWithHashes = []; + var filesWithHashes = new List(allFiles.Length); foreach (var filePath in allFiles) { @@ -532,11 +627,9 @@ private async Task> UpdateManifestsWithExtractedFiles( var relativePath = Path.GetRelativePath(extractPath, filePath); var fileInfo = new FileInfo(filePath); - // Determine if this file is inside the Maps directory var isMap = relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.MapsSubdirectory + "/", StringComparison.OrdinalIgnoreCase); - // Determine if this file is inside the GeneralsOnlineGameData directory var isGameData = relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory + "/", StringComparison.OrdinalIgnoreCase); @@ -547,151 +640,137 @@ private async Task> UpdateManifestsWithExtractedFiles( hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); } - filesWithHashes.Add((relativePath, fileInfo, hash, isMap, isGameData)); + filesWithHashes.Add(new ExtractedFileInfo(relativePath, fileInfo, hash, isMap, isGameData)); logger.LogDebug("Processed file: {File} ({Size} bytes, hash: {Hash}, isMap: {IsMap}, isGameData: {IsGameData})", relativePath, fileInfo.Length, hash[..8], isMap, isGameData); } - List updatedManifests = []; + return filesWithHashes; + } - foreach (var manifest in manifests) - { - List manifestFiles = []; - var isMapPackManifest = manifest.ContentType == ContentType.MapPack; - var isPatchManifest = manifest.ContentType == ContentType.Patch; + private List BuildManifestFilesForManifest( + ContentManifest manifest, + List filesWithHashes) + { + var manifestFiles = new List(); - if (isMapPackManifest) + if (manifest.ContentType == ContentType.MapPack) + { + foreach (var file in filesWithHashes) { - // MapPack manifest: only include map files with UserMapsDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsMap) { - if (!isMap) - { - continue; - } - - manifestFiles.Add(CreateMapManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateMapManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); } - else if (isPatchManifest) + + logger.LogInformation("MapPack manifest '{Name}' updated with {Count} map files", manifest.Name, manifestFiles.Count); + } + else if (manifest.ContentType == ContentType.Patch) + { + foreach (var file in filesWithHashes) { - // Data patch manifest: only include GeneralsOnlineGameData files with UserDataDirectory install target - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) + if (file.IsGameData) { - if (!isGameData) - { - continue; - } - - manifestFiles.Add(CreateGameDataManifestFile(relativePath, fileInfo, hash)); + manifestFiles.Add(CreateGameDataManifestFile(file.RelativePath, file.FileInfo, file.Hash)); } - - logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); } - else - { - // Game client manifest: include executables and shared files (skipping maps and game data files) - // Since 060526_QFE1 the portable ships an Easy Anti-Cheat bootstrapper that starts the - // binary named by EasyAntiCheat/Settings.json. When present it is the only launch target; - // the wrapped binary stays in the workspace as ordinary content for EAC to start. - var hasEacLauncher = filesWithHashes.Any(file => - !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - - var targetExecutable = hasEacLauncher - ? GameClientConstants.GeneralsOnlineEacLauncherExecutable - : GameClientConstants.GeneralsOnline60HzExecutable; - - foreach (var (relativePath, fileInfo, hash, isMap, isGameData) in filesWithHashes) - { - var isExecutable = false; - - // Skip map files and game data files in GameClient manifests - if (isMap || isGameData) - { - continue; - } - - if (IsArchiveRootFile(relativePath, targetExecutable)) - { - isExecutable = true; - } - manifestFiles.Add(new ManifestFile - { - RelativePath = relativePath, - Size = fileInfo.Length, - Hash = hash, - SourceType = ContentSourceType.ContentAddressable, - SourcePath = fileInfo.FullName, - InstallTarget = ContentInstallTarget.Workspace, - IsExecutable = isExecutable, - }); - } + logger.LogInformation("GameData patch manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + else + { + var hasEacLauncher = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacLauncherExecutable)); - logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); - } + var targetExecutable = hasEacLauncher + ? GameClientConstants.GeneralsOnlineEacLauncherExecutable + : GameClientConstants.GeneralsOnline60HzExecutable; - if (manifestFiles.Count == 0) + foreach (var file in filesWithHashes) { - if (isMapPackManifest || isPatchManifest) + if (file.IsMap || file.IsGameData) { - logger.LogInformation( - "Skipping empty {Type} manifest '{Name}' because no matching files were found in extract path", - manifest.ContentType, - manifest.Name); continue; } - if (manifest.ContentType == ContentType.GameClient) - { - logger.LogError( - "GameClient manifest '{Name}' has zero files in extract path '{ExtractPath}'", - manifest.Name, - extractPath); - throw new InvalidDataException( - $"GameClient manifest '{manifest.Name}' has no files in extract path '{extractPath}'."); - } + var isExecutable = IsArchiveRootFile(file.RelativePath, targetExecutable); - logger.LogError( - "Manifest '{Name}' of type {Type} has zero files in extract path '{ExtractPath}'", - manifest.Name, - manifest.ContentType, - extractPath); - throw new InvalidDataException( - $"Manifest '{manifest.Name}' of type {manifest.ContentType} has no files in extract path '{extractPath}'."); + manifestFiles.Add(new ManifestFile + { + RelativePath = file.RelativePath, + Size = file.FileInfo.Length, + Hash = file.Hash, + SourceType = ContentSourceType.ContentAddressable, + SourcePath = file.FileInfo.FullName, + InstallTarget = ContentInstallTarget.Workspace, + IsExecutable = isExecutable, + }); } - updatedManifests.Add(new ContentManifest + logger.LogInformation("GameClient manifest '{Name}' updated with {Count} files", manifest.Name, manifestFiles.Count); + } + + return manifestFiles; + } + + private InstallationInstructions BuildInstallationInstructions( + ContentManifest manifest, + List filesWithHashes) + { + var hasEacSetup = filesWithHashes.Any(file => + !file.IsMap && !file.IsGameData && + IsArchiveRootFile(file.RelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable)); + + var inheritedPostSteps = (manifest.InstallationInstructions?.PostInstallSteps ?? []) + .Where(s => s != null && (hasEacSetup || !string.Equals( + s.TargetRelativePath, + GameClientConstants.GeneralsOnlineEacSetupExecutable, + StringComparison.OrdinalIgnoreCase))); + + var instructions = new InstallationInstructions + { + WorkspaceStrategy = manifest.InstallationInstructions?.WorkspaceStrategy ?? WorkspaceConstants.DefaultWorkspaceStrategy, + DownloadHash = manifest.InstallationInstructions?.DownloadHash, + PostInstallSteps = [.. inheritedPostSteps], + }; + + if (manifest.ContentType == ContentType.GameClient && + hasEacSetup && + instructions.PostInstallSteps.All(s => s == null || (!string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase) && !string.Equals(s.StepKey, GeneralsOnlineConstants.EacStepKey, StringComparison.OrdinalIgnoreCase)))) + { + instructions.PostInstallSteps.Add(new InstallationStep { - Id = manifest.Id, - Name = manifest.Name, - Version = manifest.Version, - ContentType = manifest.ContentType, - TargetGame = manifest.TargetGame, - Publisher = manifest.Publisher, - Metadata = manifest.Metadata, - Files = manifestFiles, - Dependencies = manifest.Dependencies, + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + RequiresElevation = true, + StatusMessage = GeneralsOnlineConstants.EacStatusMessage, + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, }); } - // If MapPack was not created from archive, remove MapPack dependency so dependency resolution does not fail - var hasMapPack = updatedManifests.Any(m => m.ContentType == ContentType.MapPack); - if (!hasMapPack) + return instructions; + } + + private void ReconcileMissingMapPackDependencies(List manifests) + { + var hasMapPack = manifests.Any(m => m.ContentType == ContentType.MapPack); + if (hasMapPack) { - foreach (var m in updatedManifests) + return; + } + + foreach (var m in manifests) + { + if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) { - if (m.Dependencies.Any(d => d.DependencyType == ContentType.MapPack)) - { - logger.LogWarning( - "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", - m.Name); - m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); - } + logger.LogWarning( + "Removing MapPack dependency from manifest '{Name}' because MapPack was not found in archive", + m.Name); + m.Dependencies = m.Dependencies.Where(d => d.DependencyType != ContentType.MapPack).ToList(); } } - - return updatedManifests; } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs index 3c842779c..6a800a122 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineProvider.cs @@ -1,3 +1,9 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.Manifest; @@ -10,11 +16,6 @@ using GenHub.Core.Models.Results.Content; using GenHub.Features.Content.Services.ContentProviders; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace GenHub.Features.Content.Services.GeneralsOnline; @@ -28,10 +29,12 @@ public class GeneralsOnlineProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService, IContentManifestPool manifestPool, ILogger logger) - : BaseContentProvider(contentValidator, logger) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { + private readonly ConcurrentDictionary> _preExistingManifestIdsByManifest = new(StringComparer.OrdinalIgnoreCase); private ProviderDefinition? _cachedProviderDefinition; /// @@ -201,6 +204,12 @@ protected override async Task> PrepareContentIn IProgress? progress, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) + { + return OperationResult.CreateFailure( + "GeneralsOnline is currently supported only on Windows. Easy Anti-Cheat was not designed for Wine/Proton environments."); + } + Logger.LogInformation("Preparing Generals Online content: {Version}", manifest.Version); try @@ -212,6 +221,21 @@ protected override async Task> PrepareContentIn $"Cannot deliver content for manifest {manifest.Id}"); } + var existingPool = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (!existingPool.Success || existingPool.Data == null) + { + return OperationResult.CreateFailure( + $"Failed to query existing manifests before delivery: {existingPool.FirstError}"); + } + + var preExisting = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var m in existingPool.Data) + { + preExisting.Add(m.Id); + } + + _preExistingManifestIdsByManifest[manifest.Id] = preExisting; + var deliveryResult = await Deliverer.DeliverContentAsync( manifest, workingDirectory, @@ -241,4 +265,63 @@ protected override async Task> PrepareContentIn $"Content preparation failed: {ex.Message}"); } } + + /// + protected override async Task RollbackPreparedContentAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + Logger.LogWarning("Rolling back Generals Online manifest registration for version {Version}", preparedManifest.Version); + + try + { + if (!_preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out var preExistingIds) || preExistingIds == null) + { + Logger.LogWarning( + "No pre-delivery manifest snapshot found for {ManifestId}; skipping rollback manifest unregistration to avoid removing existing content", + originalManifest.Id); + return; + } + + var allManifestsResult = await manifestPool.GetAllManifestsAsync(cancellationToken); + if (allManifestsResult.Success && allManifestsResult.Data != null) + { + var matchingManifests = allManifestsResult.Data + .Where(m => string.Equals(m.Version, preparedManifest.Version, StringComparison.OrdinalIgnoreCase) && + string.Equals(m.Publisher?.PublisherType, GeneralsOnlineConstants.PublisherType, StringComparison.OrdinalIgnoreCase) && + !preExistingIds.Contains(m.Id)) + .ToList(); + + foreach (var manifest in matchingManifests) + { + var removeResult = await manifestPool.RemoveManifestAsync(manifest.Id, cancellationToken: cancellationToken); + if (!removeResult.Success) + { + Logger.LogWarning("Failed to remove manifest {ManifestId} during rollback: {Error}", manifest.Id, removeResult.FirstError); + } + else + { + Logger.LogInformation("Unregistered manifest {ManifestId} during rollback", manifest.Id); + } + } + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error occurred during Generals Online manifest registration rollback"); + } + } + + /// + protected override Task OnContentPreparationCompletedAsync( + ContentManifest originalManifest, + ContentManifest preparedManifest, + string workingDirectory, + CancellationToken cancellationToken) + { + _preExistingManifestIdsByManifest.TryRemove(originalManifest.Id, out _); + return Task.CompletedTask; + } } diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs index 4f9358e22..366cb6e41 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentProvider.cs @@ -24,8 +24,9 @@ public class GitHubContentProvider( IEnumerable resolvers, IEnumerable deliverers, ILogger logger, - IContentValidator contentValidator) - : BaseContentProvider(contentValidator, logger) + IContentValidator contentValidator, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { /// public override string SourceName => "GitHub"; diff --git a/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs new file mode 100644 index 000000000..bae5c14e2 --- /dev/null +++ b/GenHub/GenHub/Features/Content/Services/InstallationInstructionsService.cs @@ -0,0 +1,657 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Helpers; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using Microsoft.Extensions.Logging; + +namespace GenHub.Features.Content.Services; + +/// +/// Service for validating and executing manifest-declared installation steps. +/// Enforces trust boundaries, path containment, and hash verification before execution. +/// +/// The file hash provider for integrity verification. +/// The notification service for user awareness. +/// The user settings service for tracking executed installation steps across updates. +/// Optional installation step preconditions for environment detection. +/// The logger instance. +public class InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + IUserSettingsService? userSettingsService, + IEnumerable? preconditions, + ILogger logger) : IInstallationInstructionsService +{ + private static readonly TimeSpan InstallerStepTimeout = TimeSpan.FromMinutes(10); + private readonly SemaphoreSlim _executionGate = new(1, 1); + + /// + /// Initializes a new instance of the class. + /// + /// The file hash provider for integrity verification. + /// The notification service for user awareness. + /// The user settings service for tracking executed installation steps across updates. + /// The logger instance. + public InstallationInstructionsService( + IFileHashProvider hashProvider, + INotificationService notificationService, + IUserSettingsService? userSettingsService, + ILogger logger) + : this(hashProvider, notificationService, userSettingsService, null, logger) + { + } + + /// + public async Task ExecutePostInstallStepsAsync( + ContentManifest manifest, + string workingDirectory, + string? providerSource = null, + bool force = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.InstallationInstructions?.PostInstallSteps == null || + manifest.InstallationInstructions.PostInstallSteps.Count == 0) + { + return OperationResult.CreateSuccess(); + } + + logger.LogInformation( + "Executing {Count} post-install step(s) for manifest {ManifestId} from provider {Provider} (force: {Force})", + manifest.InstallationInstructions.PostInstallSteps.Count, + manifest.Id, + providerSource ?? "unspecified", + force); + + return await ExecuteStepsAsync( + manifest.InstallationInstructions.PostInstallSteps, + manifest, + workingDirectory, + providerSource, + force, + progress, + cancellationToken); + } + + private async Task ExecuteStepsAsync( + IReadOnlyList steps, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory) || !Directory.Exists(workingDirectory)) + { + return OperationResult.CreateFailure($"Working directory does not exist: '{workingDirectory}'"); + } + + await _executionGate.WaitAsync(cancellationToken); + try + { + for (var i = 0; i < steps.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var step = steps[i]; + + if (step == null) + { + continue; + } + + var stepResult = await ExecuteSingleStepAsync(step, manifest, workingDirectory, providerSource, force, progress, cancellationToken); + if (!stepResult.Success) + { + return stepResult; + } + } + + return OperationResult.CreateSuccess(); + } + finally + { + _executionGate.Release(); + } + } + + private async Task ExecuteSingleStepAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + string? providerSource, + bool force, + IProgress? progress, + CancellationToken cancellationToken) + { + var stepKey = GetStepKey(step, manifest); + + if (!force && step.RunOnce && await ShouldSkipStepAsync(step, stepKey, manifest, cancellationToken)) + { + logger.LogInformation( + "Skipping installation step '{StepName}' for manifest {ManifestId} because it has already been executed (key: {StepKey})", + step.Name, + manifest.Id, + stepKey); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = $"Skipping {step.Name} (already installed)", + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + + return OperationResult.CreateSuccess(); + } + + var authResult = ValidateProviderAuthorization(providerSource, manifest, step); + if (!authResult.Success) + { + return authResult; + } + + var result = OperationResult.CreateFailure("Uninitialized step result"); + switch (step.Kind) + { + case InstallationStepKind.RunVerifiedInstaller: + result = await ExecuteRunVerifiedInstallerAsync(step, manifest, workingDirectory, progress, cancellationToken); + break; + + case InstallationStepKind.RemoveFile: + result = ExecuteRemoveFile(step, workingDirectory); + break; + + case InstallationStepKind.RenameFile: + result = ExecuteRenameFile(step, workingDirectory); + break; + + default: + logger.LogError("Unsupported installation step kind '{Kind}' in step '{StepName}'", step.Kind, step.Name); + return OperationResult.CreateFailure($"Unsupported installation step kind '{step.Kind}' for step '{step.Name}'."); + } + + if (result.Success && step.RunOnce && !string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return result; + } + + private async Task ShouldSkipStepAsync( + InstallationStep step, + string stepKey, + ContentManifest manifest, + CancellationToken cancellationToken) + { + if (userSettingsService?.Get().IsInstallationStepExecuted(stepKey) == true) + { + return true; + } + + if (preconditions != null) + { + foreach (var precondition in preconditions) + { + if (precondition.CanHandle(step, manifest) && precondition.IsAlreadyFulfilled(step, manifest)) + { + if (!string.IsNullOrWhiteSpace(stepKey)) + { + await RecordStepExecutedAsync(stepKey, cancellationToken); + } + + return true; + } + } + } + + return false; + } + + private async Task RecordStepExecutedAsync(string stepKey, CancellationToken cancellationToken) + { + if (userSettingsService == null || string.IsNullOrWhiteSpace(stepKey)) + { + return; + } + + userSettingsService.Update(s => s.RecordInstallationStepExecuted(stepKey)); + + try + { + await userSettingsService.SaveAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist executed installation step key '{StepKey}'", stepKey); + } + } + + private string GetStepKey(InstallationStep step, ContentManifest manifest) + { + if (!string.IsNullOrWhiteSpace(step.StepKey)) + { + return step.StepKey; + } + + var publisher = manifest.Publisher?.PublisherType ?? "generic"; + var manifestId = manifest.Id.Value ?? string.Empty; + var name = step.Name; + var target = step.TargetRelativePath ?? string.Empty; + var args = step.Arguments is { Count: > 0 } ? string.Join(" ", step.Arguments) : string.Empty; + + return $"{publisher}:{manifestId}:{name}:{target}:{args}".TrimEnd(':'); + } + + private async Task ExecuteRunVerifiedInstallerAsync( + InstallationStep step, + ContentManifest manifest, + string workingDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + var pathResult = ValidateInstallerTargetPath(step, workingDirectory, out var targetFullPath); + if (!pathResult.Success) + { + return pathResult; + } + + var integrityResult = await VerifyInstallerIntegrityAsync(step, manifest, targetFullPath, cancellationToken); + if (!integrityResult.Success) + { + return integrityResult; + } + + NotifyStepStarting(step, progress); + + logger.LogInformation( + "Executing verified installer '{Target}' (Elevation: {RequiresElevation}) for manifest {ManifestId}", + step.TargetRelativePath, + step.RequiresElevation, + manifest.Id); + + return await RunInstallerProcessAsync(step, targetFullPath, workingDirectory, cancellationToken); + } + + private OperationResult ValidateProviderAuthorization(string? providerSource, ContentManifest manifest, InstallationStep step) + { + var effectiveSource = !string.IsNullOrWhiteSpace(providerSource) + ? providerSource + : string.Empty; + + var isTrusted = PublisherTypeConstants.TrustedExecutablePublishers.Contains(effectiveSource); + + if (!isTrusted) + { + logger.LogError( + "Untrusted provider '{ProviderSource}' attempted to execute step '{StepName}' (Kind: {Kind}) for manifest {ManifestId}", + effectiveSource, + step.Name, + step.Kind, + manifest.Id); + + return OperationResult.CreateFailure( + $"Provider '{(!string.IsNullOrEmpty(effectiveSource) ? effectiveSource : "unknown")}' is not authorized to execute installation steps."); + } + + return OperationResult.CreateSuccess(); + } + + private OperationResult ValidateInstallerTargetPath(InstallationStep step, string workingDirectory, out string targetFullPath) + { + targetFullPath = string.Empty; + + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for executable step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) + { + logger.LogError("Target installer path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Installer path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!File.Exists(targetFullPath)) + { + logger.LogError("Installer executable not found at '{Path}'", targetFullPath); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' was not found in delivered content."); + } + + return OperationResult.CreateSuccess(); + } + + private async Task VerifyInstallerIntegrityAsync( + InstallationStep step, + ContentManifest manifest, + string targetFullPath, + CancellationToken cancellationToken) + { + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath ?? string.Empty); + var manifestFile = manifest.Files?.FirstOrDefault(f => + string.Equals( + PathHelper.NormalizeRelativePath(f.RelativePath), + normalizedRelativePath, + PathHelper.PathComparison)); + + if (manifestFile == null) + { + logger.LogError("Executable '{Target}' is not declared in manifest files for {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Installer executable '{step.TargetRelativePath}' is not declared in manifest files."); + } + + if (string.IsNullOrWhiteSpace(manifestFile.Hash)) + { + logger.LogError("Installer '{Target}' has no declared hash in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure( + $"Installer '{step.TargetRelativePath}' has no declared hash and cannot be verified."); + } + + var computedHash = string.Empty; + try + { + computedHash = await hashProvider.ComputeFileHashAsync(targetFullPath, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to compute hash for installer '{Target}' in manifest {ManifestId}", step.TargetRelativePath, manifest.Id); + return OperationResult.CreateFailure($"Failed to compute hash for installer '{step.TargetRelativePath}': {ex.Message}"); + } + + if (!string.Equals(computedHash, manifestFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + logger.LogError( + "Integrity verification failed for installer '{Target}'. Expected: {Expected}, Computed: {Computed}", + step.TargetRelativePath, + manifestFile.Hash, + computedHash); + + return OperationResult.CreateFailure( + $"Integrity verification failed for installer '{step.TargetRelativePath}'."); + } + + logger.LogDebug("Integrity verified for installer '{Target}'", step.TargetRelativePath); + return OperationResult.CreateSuccess(); + } + + private void NotifyStepStarting(InstallationStep step, IProgress? progress) + { + var displayTitle = !string.IsNullOrWhiteSpace(step.Name) ? step.Name : "Running Installation Step"; + var displayMessage = !string.IsNullOrWhiteSpace(step.StatusMessage) + ? step.StatusMessage + : $"Executing verified installer '{step.TargetRelativePath}'"; + + notificationService.ShowInfo( + displayTitle, + displayMessage, + NotificationConstants.DefaultAutoDismissMs); + + progress?.Report(new ContentAcquisitionProgress + { + Phase = ContentAcquisitionPhase.Delivering, + CurrentOperation = displayMessage, + CurrentFile = step.TargetRelativePath ?? string.Empty, + }); + } + + private async Task RunInstallerProcessAsync( + InstallationStep step, + string targetFullPath, + string workingDirectory, + CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo + { + FileName = targetFullPath, + WorkingDirectory = workingDirectory, + }; + + if (step.Arguments is { Count: > 0 }) + { + foreach (var arg in step.Arguments) + { + startInfo.ArgumentList.Add(arg); + } + } + + if (step.RequiresElevation) + { + if (!OperatingSystem.IsWindows()) + { + logger.LogError("Installation step '{StepName}' requires administrator elevation, which is only supported on Windows", step.Name); + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' requires administrator elevation, which is only supported on Windows."); + } + + startInfo.UseShellExecute = true; + startInfo.Verb = "runas"; + } + else + { + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + } + + try + { + using var process = Process.Start(startInfo); + if (process == null) + { + logger.LogError("Failed to start process for installer '{Target}'", step.TargetRelativePath); + notificationService.ShowError("Installation Step Failed", $"Failed to start installer '{step.Name}'."); + return OperationResult.CreateFailure($"Failed to start installer '{step.TargetRelativePath}'."); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(InstallerStepTimeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError("Installer step '{StepName}' timed out", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate timed-out installer step '{StepName}'", step.Name); + } + + notificationService.ShowError("Installation Step Failed", $"Step '{step.Name}' timed out."); + return OperationResult.CreateFailure($"Installation step '{step.Name}' timed out."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Installation step '{StepName}' was canceled by caller, killing process tree", step.Name); + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + catch (Exception killEx) + { + logger.LogWarning(killEx, "Failed to terminate canceled installer step '{StepName}'", step.Name); + } + + throw; + } + + if (process.ExitCode != 0) + { + logger.LogError( + "Installer step '{StepName}' exited with error code {ExitCode}", + step.Name, + process.ExitCode); + + notificationService.ShowError( + "Installation Step Failed", + $"Step '{step.Name}' failed with exit code {process.ExitCode}."); + + return OperationResult.CreateFailure( + $"Installation step '{step.Name}' failed with exit code {process.ExitCode}."); + } + + logger.LogInformation("Successfully completed installer step '{StepName}'", step.Name); + notificationService.ShowSuccess( + "Installation Step Completed", + $"Successfully completed '{step.Name}'."); + + return OperationResult.CreateSuccess(); + } + catch (OperationCanceledException) + { + logger.LogInformation("Installation step '{StepName}' was canceled", step.Name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute installer step '{StepName}'", step.Name); + notificationService.ShowError( + "Installation Step Error", + $"Error executing '{step.Name}': {ex.Message}"); + + return OperationResult.CreateFailure($"Execution of step '{step.Name}' failed: {ex.Message}"); + } + } + + private OperationResult ExecuteRemoveFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for remove file step '{step.Name}'."); + } + + var normalizedRelativePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var targetFullPath = Path.Combine(workingDirectory, normalizedRelativePath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, targetFullPath)) + { + logger.LogError("Target remove path '{Target}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Target file '{step.TargetRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(targetFullPath)) + { + File.Delete(targetFullPath); + logger.LogInformation("Deleted file '{Target}' as part of step '{StepName}'", step.TargetRelativePath, step.Name); + } + else + { + logger.LogDebug("File '{Target}' already absent during remove step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete file '{Target}' in step '{StepName}'", step.TargetRelativePath, step.Name); + return OperationResult.CreateFailure($"Failed to delete file '{step.TargetRelativePath}': {ex.Message}"); + } + } + + private OperationResult ExecuteRenameFile(InstallationStep step, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(step.TargetRelativePath)) + { + return OperationResult.CreateFailure($"Target relative path is required for rename step '{step.Name}'."); + } + + if (string.IsNullOrWhiteSpace(step.DestinationRelativePath)) + { + return OperationResult.CreateFailure($"Destination relative path is required for rename step '{step.Name}'."); + } + + var normalizedSourcePath = PathHelper.NormalizeRelativePath(step.TargetRelativePath); + var normalizedDestPath = PathHelper.NormalizeRelativePath(step.DestinationRelativePath); + + var sourceFullPath = Path.Combine(workingDirectory, normalizedSourcePath); + var destFullPath = Path.Combine(workingDirectory, normalizedDestPath); + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, sourceFullPath)) + { + logger.LogError("Source path '{Source}' escapes working directory '{Dir}'", step.TargetRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Source path '{step.TargetRelativePath}' escapes the working directory."); + } + + if (!PathHelper.IsPathWithinDirectory(workingDirectory, destFullPath)) + { + logger.LogError("Destination path '{Dest}' escapes working directory '{Dir}'", step.DestinationRelativePath, workingDirectory); + return OperationResult.CreateFailure($"Destination path '{step.DestinationRelativePath}' escapes the working directory."); + } + + try + { + if (File.Exists(sourceFullPath)) + { + var destDir = Path.GetDirectoryName(destFullPath); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); + } + + File.Move(sourceFullPath, destFullPath, overwrite: true); + logger.LogInformation( + "Renamed '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + } + else + { + logger.LogWarning("Source file '{Source}' does not exist for rename step '{StepName}'", step.TargetRelativePath, step.Name); + } + + return OperationResult.CreateSuccess(); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to rename '{Source}' to '{Dest}' in step '{StepName}'", + step.TargetRelativePath, + step.DestinationRelativePath, + step.Name); + + return OperationResult.CreateFailure( + $"Failed to rename '{step.TargetRelativePath}' to '{step.DestinationRelativePath}': {ex.Message}"); + } + } +} diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index 3f1b78008..d653e90f4 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -28,8 +28,9 @@ public class SuperHackersProvider( IEnumerable resolvers, IEnumerable deliverers, IContentValidator contentValidator, - ILogger logger) - : BaseContentProvider(contentValidator, logger) + ILogger logger, + IInstallationInstructionsService installationInstructionsService) + : BaseContentProvider(contentValidator, installationInstructionsService, logger) { private readonly IContentResolver _resolver = resolvers.FirstOrDefault(r => r.ResolverId?.Equals(SuperHackersConstants.ResolverId, StringComparison.OrdinalIgnoreCase) == true) diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index 933b635d9..766532f76 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -956,7 +956,7 @@ private async Task> AdoptExpectedChildProcessAs // Terminated first, so the launcher has exited and its stderr drains in full. return OperationResult.CreateFailure( AppendLauncherErrors( - $"Cannot adopt {expectedName}: the launcher's start time could not be read.", + $"Launcher exited without starting {expectedName}: the launcher's start time could not be read.", launcher, capturedErrors)); } diff --git a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs index fcecce36f..828107c9c 100644 --- a/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs +++ b/GenHub/GenHub/Features/Manifest/ContentManifestBuilder.cs @@ -255,6 +255,28 @@ public IContentManifestBuilder WithPublisher( return this; } + /// + public IContentManifestBuilder WithPublisher(PublisherInfo publisher) + { + ArgumentNullException.ThrowIfNull(publisher); + + _manifest.Publisher = new PublisherInfo + { + Name = publisher.Name, + PublisherType = publisher.PublisherType, + Website = publisher.Website, + SupportUrl = publisher.SupportUrl, + ContactEmail = publisher.ContactEmail, + UpdateApiEndpoint = publisher.UpdateApiEndpoint, + ContentIndexUrl = publisher.ContentIndexUrl, + UpdateCheckIntervalHours = publisher.UpdateCheckIntervalHours, + SupportsIncrementalUpdates = publisher.SupportsIncrementalUpdates, + AuthenticationMethod = publisher.AuthenticationMethod, + }; + logger.LogDebug("Set publisher: {PublisherName} (Type: {PublisherType})", publisher.Name, publisher.PublisherType); + return this; + } + /// /// Sets the metadata for the manifest. /// @@ -358,6 +380,16 @@ public IContentManifestBuilder AddContentReference( return this; } + /// + public IContentManifestBuilder WithContentReferences(IEnumerable contentReferences) + { + ArgumentNullException.ThrowIfNull(contentReferences); + + _manifest.ContentReferences = [.. contentReferences]; + logger.LogDebug("Set {Count} content references", _manifest.ContentReferences.Count); + return this; + } + /// /// Adds files from a directory to the manifest. /// @@ -512,6 +544,7 @@ public Task AddContentAddressableFileAsync( { RelativePath = relativePath, SourceType = ContentSourceType.ContentAddressable, + InstallTarget = DetermineInstallTarget(relativePath), IsExecutable = isExecutable, Hash = hash, Size = size, @@ -614,69 +647,86 @@ public IContentManifestBuilder AddRequiredDirectories(params string[] directorie public IContentManifestBuilder WithInstallationInstructions( WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy) { - _manifest.InstallationInstructions = new InstallationInstructions - { - WorkspaceStrategy = workspaceStrategy, - }; + _manifest.InstallationInstructions = _manifest.InstallationInstructions == null + ? new InstallationInstructions { WorkspaceStrategy = workspaceStrategy } + : new InstallationInstructions + { + WorkspaceStrategy = workspaceStrategy, + DownloadHash = _manifest.InstallationInstructions.DownloadHash, + PostInstallSteps = _manifest.InstallationInstructions.PostInstallSteps == null + ? [] + : [.. _manifest.InstallationInstructions.PostInstallSteps], + }; + logger.LogDebug("Set workspace strategy: {Strategy}", workspaceStrategy); return this; } - /// - /// Adds a pre-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. - public IContentManifestBuilder AddPreInstallStep( - string name, - string command, - List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + /// + public IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions) { - var step = new InstallationStep + ArgumentNullException.ThrowIfNull(installationInstructions); + + _manifest.InstallationInstructions = new InstallationInstructions { - Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, - RequiresElevation = requiresElevation, + WorkspaceStrategy = installationInstructions.WorkspaceStrategy, + DownloadHash = installationInstructions.DownloadHash, + PostInstallSteps = installationInstructions.PostInstallSteps == null + ? [] + : [.. installationInstructions.PostInstallSteps], }; - _manifest.InstallationInstructions.PreInstallSteps.Add(step); - logger.LogDebug("Added pre-install step: {StepName}", name); + + logger.LogDebug( + "Set installation instructions with strategy {Strategy}, {PostCount} post-install steps", + _manifest.InstallationInstructions.WorkspaceStrategy, + _manifest.InstallationInstructions.PostInstallSteps.Count); return this; } - /// - /// Adds a post-installation step to the manifest. - /// - /// Step name. - /// Command. - /// Arguments. - /// Working directory. - /// Requires elevation. - /// The builder instance. + /// public IContentManifestBuilder AddPostInstallStep( string name, - string command, + InstallationStepKind kind, + string? targetRelativePath = null, List? arguments = null, - string workingDirectory = "", - bool requiresElevation = false) + string? destinationRelativePath = null, + bool requiresElevation = false, + string? statusMessage = null, + bool runOnce = false, + string? stepKey = null) { var step = new InstallationStep { Name = name, - Command = command, - Arguments = arguments ?? [], - WorkingDirectory = workingDirectory, + Kind = kind, + TargetRelativePath = targetRelativePath, + Arguments = arguments, + DestinationRelativePath = destinationRelativePath, RequiresElevation = requiresElevation, + StatusMessage = statusMessage, + RunOnce = runOnce, + StepKey = stepKey, }; + return AddPostInstallStep(step); + } + + /// + public IContentManifestBuilder AddPostInstallStep(InstallationStep step) + { + ArgumentNullException.ThrowIfNull(step); + if (step.Kind == InstallationStepKind.Unknown) + { + throw new ArgumentException("Installation step kind cannot be Unknown.", nameof(step)); + } + + if (string.IsNullOrWhiteSpace(step.Name)) + { + throw new ArgumentException("Installation step name cannot be empty or whitespace.", nameof(step)); + } + + _manifest.InstallationInstructions ??= new InstallationInstructions(); _manifest.InstallationInstructions.PostInstallSteps.Add(step); - logger.LogDebug("Added post-install step: {StepName}", name); + logger.LogDebug("Added post-install step: {StepName} (Kind: {Kind}, RunOnce: {RunOnce})", step.Name, step.Kind, step.RunOnce); return this; } diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs index ea7246dec..322c7bbe2 100644 --- a/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/ContentPipelineModule.cs @@ -361,5 +361,11 @@ private static void AddSharedComponents(IServiceCollection services) // Register content orchestrator and validator services.AddSingleton(); + + // Register installation step preconditions + services.AddSingleton(); + + // Register installation instructions execution service + services.AddSingleton(); } } From 371741c52722d76b48272e5b652f2a5bbd91882d Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:02:07 +0200 Subject: [PATCH 18/20] feat(ui): redesign header navigation tabs to centered pills and move info/settings to titlebar (#407) --- GenHub/GenHub/Common/Views/MainView.axaml | 188 ++++++++++-------- GenHub/GenHub/Common/Views/MainWindow.axaml | 95 +++++++-- .../Views/GameProfileContentEditorView.axaml | 8 +- .../GameProfileContentSettingsView.axaml | 7 - .../Extensions/NavigationTabExtensions.cs | 2 + 5 files changed, 192 insertions(+), 108 deletions(-) diff --git a/GenHub/GenHub/Common/Views/MainView.axaml b/GenHub/GenHub/Common/Views/MainView.axaml index 71eb8f4d8..086b651aa 100644 --- a/GenHub/GenHub/Common/Views/MainView.axaml +++ b/GenHub/GenHub/Common/Views/MainView.axaml @@ -29,69 +29,92 @@ - - + + + - - - + + + + + + + + + + + + @@ -44,23 +90,46 @@ Foreground="White" /> - + - + + + + + + @@ -205,9 +201,6 @@ IsVisible="{Binding Publisher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> - - - diff --git a/GenHub/GenHub/Infrastructure/Extensions/NavigationTabExtensions.cs b/GenHub/GenHub/Infrastructure/Extensions/NavigationTabExtensions.cs index 912c336ff..44aacee1e 100644 --- a/GenHub/GenHub/Infrastructure/Extensions/NavigationTabExtensions.cs +++ b/GenHub/GenHub/Infrastructure/Extensions/NavigationTabExtensions.cs @@ -14,10 +14,12 @@ public static class NavigationTabExtensions /// The display name for the tab. public static string ToDisplayString(this NavigationTab tab) => tab switch { + NavigationTab.Home => "Home", NavigationTab.GameProfiles => "Game Profiles", NavigationTab.Downloads => "Downloads", NavigationTab.Tools => "Tools", NavigationTab.Settings => "Settings", + NavigationTab.Info => "Info", _ => tab.ToString(), }; } \ No newline at end of file From 42a49166fcf2168844f3b63f90f0bf7730490d50 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 07:16:58 +0200 Subject: [PATCH 19/20] feat(ui): add ImageCacheService, ImageLoader control, and Avalonia value converters --- .../Constants/ImageCacheConstants.cs | 37 ++ GenHub/GenHub.Core/Constants/UiConstants.cs | 45 ++ GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs | 145 +++++ .../GenHub.Core/Models/Enums/ContentState.cs | 23 + .../Converters/StripHtmlConverterTests.cs | 82 +++ .../Helpers/HtmlTextHelperTests.cs | 147 +++++ .../ApplicationDataPathConventionTests.cs | 3 + .../Converters/StringToImageConverterTests.cs | 6 +- .../Services/ImageCacheServiceTests.cs | 97 +++ .../Infrastructure/Controls/ImageLoader.cs | 111 ++++ .../Converters/BoolToBackgroundConverter.cs | 37 ++ .../Converters/BoolToBorderConverter.cs | 37 ++ .../Converters/ComparisonConverters.cs | 5 + .../ContentStateToBrushConverter.cs | 41 ++ .../ContentStateToPathDataConverter.cs | 38 ++ .../Converters/ContentStateToTextConverter.cs | 32 + .../Converters/GameTypeInitialConverter.cs | 45 ++ .../Converters/IndentToMarginConverter.cs | 40 ++ .../Converters/NotEqualToConverter.cs | 38 ++ .../Converters/StringToImageConverter.cs | 20 +- .../Converters/StripHtmlConverter.cs | 39 ++ .../Services/ImageCacheService.cs | 613 ++++++++++++++++++ scripts/build-check.ps1 | 249 +++++++ 23 files changed, 1920 insertions(+), 10 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/ImageCacheConstants.cs create mode 100644 GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/ContentState.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs create mode 100644 GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs create mode 100644 scripts/build-check.ps1 diff --git a/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs new file mode 100644 index 000000000..a10b32379 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for image downloading, validation, and caching. +/// +public static class ImageCacheConstants +{ + /// + /// Maximum allowed image download payload in bytes (15 MB). + /// + public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024; + + /// + /// Maximum number of bitmap entries stored in the memory LRU cache. + /// + public const int MaxMemoryCacheEntries = 200; + + /// + /// Maximum disk cache size in bytes (250 MB). + /// + public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024; + + /// + /// Time-to-live for disk-cached images in days. + /// + public const int DiskCacheTtlDays = 30; + + /// + /// Default HTTP timeout in seconds for downloading images. + /// + public const int DefaultTimeoutSeconds = 30; + + /// + /// Maximum allowed HTTP redirects when downloading images. + /// + public const int MaxRedirects = 5; +} diff --git a/GenHub/GenHub.Core/Constants/UiConstants.cs b/GenHub/GenHub.Core/Constants/UiConstants.cs index e3dc98279..bd61a5893 100644 --- a/GenHub/GenHub.Core/Constants/UiConstants.cs +++ b/GenHub/GenHub.Core/Constants/UiConstants.cs @@ -25,6 +25,21 @@ public static class UiConstants /// public const double DefaultProfileSettingsHeight = 700; + /// + /// Default width for the profile settings sidebar in pixels. + /// + public const double DefaultProfileSettingsSidebarWidth = 190; + + /// + /// Minimum width for the profile settings sidebar (shows icons only) in pixels. + /// + public const double MinProfileSettingsSidebarWidth = 68; + + /// + /// Maximum width for the profile settings sidebar in pixels. + /// + public const double MaxProfileSettingsSidebarWidth = 300; + // Status colors /// @@ -37,6 +52,36 @@ public static class UiConstants /// public const string StatusErrorColor = "#F44336"; + /// + /// color used for downloaded status indicator. + /// + public const string StatusDownloadedColor = "#4CAF50"; + + /// + /// color used for not downloaded status indicator. + /// + public const string StatusNotDownloadedColor = "#B388FF"; + + /// + /// color used for update available status indicator. + /// + public const string StatusUpdateAvailableColor = "#FFB74D"; + + /// + /// svg path data for transparent checkmark icon. + /// + public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"; + + /// + /// svg path data for detailed download arrow icon into tray. + /// + public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"; + + /// + /// svg path data for update sync icon. + /// + public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"; + /// /// Default theme color for Generals content. /// diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs new file mode 100644 index 000000000..b19f11d94 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities, +/// and normalizing text descriptions for display across the application. +/// +public static partial class HtmlTextHelper +{ + /// + /// Converts an HTML snippet or formatted description into clean, normalized plain text: + /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks. + /// - Strips all remaining HTML tags. + /// - Decodes HTML entities (e.g., &amp;, &quot;, &gt;, &nbsp;). + /// - Normalizes whitespace and excessive blank lines. + /// - Uses the platform newline format. + /// + /// The raw HTML or formatted text string to normalize. + /// Normalized plain text, or empty string if input is null or whitespace. + public static string NormalizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + // 0. Remove script and style elements along with their contents + var text = ScriptTagRegex().Replace(html, string.Empty); + text = StyleTagRegex().Replace(text, string.Empty); + + // 1. Convert
tags to newline + text = BrTagRegex().Replace(text, "\n"); + + // 2. Convert paragraph closing tags to double newline for paragraph separation + text = ParagraphCloseTagRegex().Replace(text, "\n\n"); + + // 3. Convert other block-level closing tags and
tags to newline + text = BlockCloseTagRegex().Replace(text, "\n"); + + // 4. Strip all remaining HTML/XML tags + text = HtmlTagRegex().Replace(text, string.Empty); + + // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.) + text = WebUtility.HtmlDecode(text); + + // 6. Normalize non-breaking spaces and line endings + text = text.Replace('\u00A0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + // 7. Clean trailing whitespace on lines and collapse excess blank lines + text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n"); + text = ExcessBlankLinesRegex().Replace(text, "\n\n"); + + // 8. Trim and unify with environment newline + text = text.Trim(); + text = text.Replace("\n", Environment.NewLine); + + return text; + } + + /// + /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags, + /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis. + /// + /// The input HTML or text string. + /// Optional maximum character length including ellipsis. + /// A single-line plain text summary. + public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null) + { + if (string.IsNullOrWhiteSpace(htmlOrText)) + { + return string.Empty; + } + + // Strip HTML if tags exist, decode entities, and normalize + var text = NormalizeHtml(htmlOrText); + + // Collapse all newlines, tabs, and multiple spaces into a single space + text = MultiWhitespaceRegex().Replace(text, " ").Trim(); + + if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value) + { + return TruncateWithEllipsis(text, maxLength.Value); + } + + return text; + } + + /// + /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated. + /// + /// The text to truncate. + /// The maximum allowed length (including the ellipsis). + /// The truncated text with an ellipsis if it exceeded maxLength, or the original text. + public static string TruncateWithEllipsis(string? text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + if (maxLength <= 3) + { + return text[..maxLength]; + } + + return string.Concat(text.AsSpan(0, maxLength - 3), "..."); + } + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ScriptTagRegex(); + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StyleTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BrTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ParagraphCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BlockCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.CultureInvariant)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)] + private static partial Regex TrailingWhitespaceBeforeNewlineRegex(); + + [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)] + private static partial Regex ExcessBlankLinesRegex(); + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)] + private static partial Regex MultiWhitespaceRegex(); +} diff --git a/GenHub/GenHub.Core/Models/Enums/ContentState.cs b/GenHub/GenHub.Core/Models/Enums/ContentState.cs new file mode 100644 index 000000000..c69305f69 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/ContentState.cs @@ -0,0 +1,23 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Content state for UI display - determines which button to show. +/// +public enum ContentState +{ + /// + /// Content has not been downloaded yet. Show "Download" button. + /// + NotDownloaded, + + /// + /// Content exists locally but a newer version is available (same publisher+name, newer date). + /// Show "Update" button. + /// + UpdateAvailable, + + /// + /// Content is downloaded and up-to-date. Show "Add to Profile" dropdown. + /// + Downloaded, +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs new file mode 100644 index 000000000..f163c0603 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Globalization; +using GenHub.Infrastructure.Converters; +using Xunit; + +namespace GenHub.Tests.Core.Converters; + +/// +/// Unit tests for . +/// +public sealed class StripHtmlConverterTests +{ + private readonly StripHtmlConverter _converter = new(); + + /// + /// Verifies Convert strips HTML tags and normalizes text. + /// + [Fact] + public void Convert_WithHtmlMarkup_StripsTags() + { + var input = "

Test content with links.

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Test content with links.", result); + } + + /// + /// Verifies Convert with integer parameter truncates and single-lines text. + /// + [Fact] + public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert with string parameter parses integer and truncates. + /// + [Fact] + public void Convert_WithStringParameter_ParsesAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_WithScriptAndStyleTags_StripsContents() + { + var input = "

Hello World

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Hello World", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_NonStringValue_ReturnsOriginalValue() + { + var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture); + Assert.Equal(42, result); + } + + /// + /// Verifies ConvertBack throws NotSupportedException. + /// + [Fact] + public void ConvertBack_ThrowsNotSupportedException() + { + Assert.Throws(() => + _converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs new file mode 100644 index 000000000..574b95262 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs @@ -0,0 +1,147 @@ +using System; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class HtmlTextHelperTests +{ + /// + /// Verifies that NormalizeHtml returns an empty string when input is null or whitespace. + /// + /// The test input string. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void NormalizeHtml_NullOrWhitespace_ReturnsEmptyString(string? input) + { + var result = HtmlTextHelper.NormalizeHtml(input); + Assert.Equal(string.Empty, result); + } + + /// + /// Verifies that NormalizeHtml converts paragraph tags into paragraphs separated by newlines. + /// + [Fact] + public void NormalizeHtml_ParagraphTags_ConvertsToParagraphsAndStripsTags() + { + var html = "

First paragraph.

Second paragraph.

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph.{Environment.NewLine}{Environment.NewLine}Second paragraph."; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml converts break tags to line breaks. + /// + [Fact] + public void NormalizeHtml_BreakTags_ConvertsToNewlines() + { + var html = "Line 1
Line 2
Line 3
Line 4"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"Line 1{Environment.NewLine}Line 2{Environment.NewLine}Line 3{Environment.NewLine}Line 4"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml strips inline HTML formatting tags. + /// + [Fact] + public void NormalizeHtml_InlineTags_StripsTagsCleanly() + { + var html = "Bold Italic Link Text"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("Bold Italic Link Text", result); + } + + /// + /// Verifies that NormalizeHtml decodes HTML entities into appropriate characters. + /// + [Fact] + public void NormalizeHtml_HtmlEntities_DecodesCorrectly() + { + var html = ""Hello & Welcome's <World>"   –"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("\"Hello & Welcome's \" –", result); + } + + /// + /// Verifies that NormalizeHtml strips paragraph tags from CNC Labs description snippets. + /// + [Fact] + public void NormalizeHtml_CncLabsDescriptionWithPTags_ResolvesCleanly() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("The Ships and Boats War map is a game map that takes place almost", result); + } + + /// + /// Verifies that NormalizeHtml collapses runs of excess blank lines to a double newline. + /// + [Fact] + public void NormalizeHtml_ExcessBlankLines_CollapsedToDoubleNewline() + { + var html = "First paragraph\n\n\n\n\nSecond paragraph"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph{Environment.NewLine}{Environment.NewLine}Second paragraph"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that CleanToSingleLine collapses multiple whitespace characters and newlines into a single space. + /// + [Fact] + public void CleanToSingleLine_WithHtmlAndNewlines_CollapsesWhitespace() + { + var html = "

First line

\n\n

Second line\twith spaces

"; + var result = HtmlTextHelper.CleanToSingleLine(html); + + Assert.Equal("First line Second line with spaces", result); + } + + /// + /// Verifies that CleanToSingleLine truncates strings exceeding maximum length and appends an ellipsis. + /// + [Fact] + public void CleanToSingleLine_WithMaxLength_TruncatesWithEllipsis() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.CleanToSingleLine(html, 30); + + Assert.Equal(30, result.Length); + Assert.EndsWith("...", result, StringComparison.Ordinal); + Assert.Equal("The Ships and Boats War map...", result); + } + + /// + /// Verifies that TruncateWithEllipsis handles various length inputs and edge cases. + /// + /// The test input string. + /// The maximum allowed length. + /// The expected truncated output. + [Theory] + [InlineData(null, 10, "")] + [InlineData("", 10, "")] + [InlineData("Short text", 20, "Short text")] + [InlineData("ExactLengthText", 15, "ExactLengthText")] + [InlineData("A very long string exceeding limit", 10, "A very ...")] + [InlineData("Abcdef", 3, "Abc")] + [InlineData("Abcdef", 2, "Ab")] + public void TruncateWithEllipsis_VariousInputs_BehavesCorrectly(string? input, int maxLength, string expected) + { + var result = HtmlTextHelper.TruncateWithEllipsis(input, maxLength); + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs index a0aaa1275..e9c2a95be 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs @@ -42,6 +42,9 @@ public class ApplicationDataPathConventionTests // Core-layer fallback, overridden at the composition root by ContentPipelineModule. ["ProviderDefinitionLoader.cs"] = "Default only; the DI registration supplies an override.", + + // UI image cache service initialized outside DI container. + ["ImageCacheService.cs"] = "Static singleton image cache initialized outside DI.", }; /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs index 666f4a002..c63be62bb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs @@ -97,12 +97,12 @@ public void Convert_WithAvarUri_DoesNotReturnNull() } /// - /// Tests that throws . + /// Tests that throws . /// [Fact] - public void ConvertBack_ThrowsNotImplementedException() + public void ConvertBack_ThrowsNotSupportedException() { - Assert.Throws(() => + Assert.Throws(() => _converter.ConvertBack(null, typeof(string), null, _culture)); } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs new file mode 100644 index 000000000..40d247a50 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Threading.Tasks; +using GenHub.Infrastructure.Services; +using Xunit; + +namespace GenHub.Tests.Core.Infrastructure.Services; + +/// +/// Unit tests for security methods. +/// +public class ImageCacheServiceTests +{ + /// + /// Verifies that private and loopback IPv4/IPv6 addresses are rejected as unsafe. + /// + /// The IP string to test. + [Theory] + [InlineData("127.0.0.1")] + [InlineData("10.0.0.1")] + [InlineData("172.16.0.1")] + [InlineData("172.31.255.255")] + [InlineData("192.168.1.1")] + [InlineData("169.254.1.1")] + [InlineData("100.64.0.1")] + [InlineData("::1")] + [InlineData("fc00::1")] + [InlineData("fe80::1")] + public void IsSafeIpAddress_PrivateOrLoopback_ReturnsFalse(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.False(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that public routable IP addresses are accepted as safe. + /// + /// The IP string to test. + [Theory] + [InlineData("8.8.8.8")] + [InlineData("1.1.1.1")] + [InlineData("142.250.190.46")] + [InlineData("2606:4700:4700::1111")] + public void IsSafeIpAddress_PublicRoutableIp_ReturnsTrue(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.True(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that localhost and invalid hostnames are rejected by . + /// + /// The host to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("localhost")] + [InlineData("127.0.0.1")] + [InlineData("192.168.0.1")] + [InlineData("")] + public async Task IsSafeHostAsync_UnsafeHost_ReturnsFalseAsync(string host) + { + var result = await ImageCacheService.IsSafeHostAsync(host); + Assert.False(result); + } + + /// + /// Verifies that non-HTTP/HTTPS and UNC paths are rejected by . + /// + /// The URL to test. + [Theory] + [InlineData("file:///C:/secret.txt")] + [InlineData("custom://example.com/image.png")] + [InlineData("\\\\server\\share\\image.png")] + [InlineData("javascript:alert(1)")] + [InlineData("https://localhost/test.png")] + [InlineData("https://127.0.0.1/test.png")] + [InlineData("https://192.168.1.1/test.png")] + public void IsSafeRemoteUrl_UnsafeUrl_ReturnsFalse(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out _); + Assert.False(result); + } + + /// + /// Verifies that valid public HTTP/HTTPS URLs are accepted. + /// + /// The URL to test. + [Theory] + [InlineData("https://example.com/image.png")] + [InlineData("https://cdn.playgenerals.online/images/cover.jpg")] + [InlineData("https://8.8.8.8/image.jpg")] + public void IsSafeRemoteUrl_SafeUrl_ReturnsTrue(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out var uri); + Assert.True(result); + Assert.NotNull(uri); + } +} diff --git a/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs b/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs new file mode 100644 index 000000000..981d253fe --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs @@ -0,0 +1,111 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; +using GenHub.Infrastructure.Services; + +namespace GenHub.Infrastructure.Controls; + +/// +/// Attached property for asynchronously loading and caching image URLs onto Avalonia Image controls. +/// +public static class ImageLoader +{ + /// + /// Identifies the Source attached property. + /// + public static readonly AttachedProperty SourceProperty = + AvaloniaProperty.RegisterAttached("Source", typeof(ImageLoader)); + + static ImageLoader() + { + SourceProperty.Changed.AddClassHandler(OnSourceChanged); + } + + /// + /// Gets the Source property value. + /// + /// The Image control. + /// The string image URL or path. + public static string? GetSource(Image element) => element.GetValue(SourceProperty); + + /// + /// Sets the Source property value. + /// + /// The Image control. + /// The string image URL or path. + public static void SetSource(Image element, string? value) => element.SetValue(SourceProperty, value); + + private static void OnSourceChanged(Image image, AvaloniaPropertyChangedEventArgs e) + { + image.AttachedToVisualTree -= OnAttachedToVisualTree; + + var url = e.NewValue as string; + if (string.IsNullOrWhiteSpace(url)) + { + image.Source = null; + return; + } + + image.AttachedToVisualTree += OnAttachedToVisualTree; + _ = ApplySourceAsync(image, url); + } + + private static void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + if (sender is not Image image) + { + return; + } + + var url = GetSource(image); + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + if (image.Source != null) + { + InvalidateImage(image); + return; + } + + _ = ApplySourceAsync(image, url); + } + + private static async Task ApplySourceAsync(Image image, string url) + { + var bitmap = ImageCacheService.Instance.GetBitmapFromMemory(url) + ?? await ImageCacheService.Instance.GetBitmapAsync(url); + + if (bitmap == null || GetSource(image) != url) + { + return; + } + + void SetBitmap() + { + if (GetSource(image) == url) + { + image.Source = bitmap; + InvalidateImage(image); + } + } + + if (Dispatcher.UIThread.CheckAccess()) + { + SetBitmap(); + } + else + { + await Dispatcher.UIThread.InvokeAsync(SetBitmap); + } + } + + private static void InvalidateImage(Image image) + { + image.InvalidateMeasure(); + image.InvalidateArrange(); + image.InvalidateVisual(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs b/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs new file mode 100644 index 000000000..26af067fe --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a boolean selection state to a background brush for selectable cards. +/// +public class BoolToBackgroundConverter : IValueConverter +{ + private static readonly IBrush Selected = new SolidColorBrush(Color.FromArgb(60, 171, 71, 188)); + private static readonly IBrush Unselected = new SolidColorBrush(Color.Parse("#252525")); + + /// + /// Converts a boolean to the matching background brush. + /// + /// The boolean value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A for the selected or unselected state. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Selected : Unselected; + + /// + /// Converts back from a brush to a boolean. Not implemented. + /// + /// The brush value to convert back. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// This method is not implemented and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs b/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs new file mode 100644 index 000000000..42d950918 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a boolean selection state to a border brush for selectable cards. +/// +public class BoolToBorderConverter : IValueConverter +{ + private static readonly IBrush Selected = new SolidColorBrush(Color.Parse("#AB47BC")); + private static readonly IBrush Unselected = Brushes.Transparent; + + /// + /// Converts a boolean to the matching border brush. + /// + /// The boolean value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A for the selected or unselected state. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Selected : Unselected; + + /// + /// Converts back from a brush to a boolean. Not implemented. + /// + /// The brush value to convert back. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// This method is not implemented and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs b/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs index 30dabbd50..42c110dee 100644 --- a/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs +++ b/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs @@ -36,6 +36,11 @@ public static class ComparisonConverters public static readonly IValueConverter IsPositive = new FuncValueConverter( count => count > 0); + /// + /// A value converter that returns true if the value is not equal to the converter parameter. + /// + public static readonly IValueConverter IsNotEqualTo = new NotEqualToConverter(); + private static bool TryGetDouble(object? value, out double result) { if (value == null) diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs new file mode 100644 index 000000000..08fd8c005 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// converts a content state enum value to a corresponding status brush. +/// +public class ContentStateToBrushConverter : IValueConverter +{ + private static readonly IBrush DownloadedBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusDownloadedColor)); + private static readonly IBrush NotDownloadedBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusNotDownloadedColor)); + private static readonly IBrush UpdateAvailableBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusUpdateAvailableColor)); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ContentState state) + { + return state switch + { + ContentState.Downloaded => DownloadedBrush, + ContentState.UpdateAvailable => UpdateAvailableBrush, + ContentState.NotDownloaded => NotDownloadedBrush, + _ => NotDownloadedBrush, + }; + } + + return NotDownloadedBrush; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs new file mode 100644 index 000000000..c24830e71 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// converts a content state enum value to svg path data for vector icon rendering. +/// +public class ContentStateToPathDataConverter : IValueConverter +{ + private static readonly Dictionary IconPaths = new() + { + [ContentState.Downloaded] = UiConstants.TransparentCheckmarkIconPath, + [ContentState.NotDownloaded] = UiConstants.DownloadArrowIconPath, + [ContentState.UpdateAvailable] = UiConstants.UpdateSyncIconPath, + }; + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ContentState state && IconPaths.TryGetValue(state, out var path)) + { + return path; + } + + return UiConstants.DownloadArrowIconPath; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs new file mode 100644 index 000000000..ef7d0097f --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a enum value to a compact emoji indicator +/// suitable for space-constrained UI like the variant dropdown. +/// +public class ContentStateToTextConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is ContentState state + ? state switch + { + ContentState.Downloaded => "✅", + ContentState.UpdateAvailable => "🔄", + _ => "⇩", + } + : "⇩"; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs b/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs new file mode 100644 index 000000000..008240bbf --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a game type value (enum or its string representation) to a short initial for display. +/// +public class GameTypeInitialConverter : IValueConverter +{ + /// + /// Converts a game type value to its display initial. + /// + /// The game type value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A short string initial representing the game type. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var text = value?.ToString(); + + if (string.IsNullOrEmpty(text)) + return "?"; + + return text switch + { + "ZeroHour" => "ZH", + "Generals" => "G", + _ => text[..1].ToUpperInvariant(), + }; + } + + /// + /// Converts back from an initial to a game type. Not implemented. + /// + /// The value produced by the binding target. + /// The type to convert to. + /// The converter parameter to use. + /// The culture to use in the converter. + /// This conversion is not supported and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs b/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs new file mode 100644 index 000000000..88d3fe0ab --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts an integer indent level to a left-margin Thickness for nested items. +/// +public class IndentToMarginConverter : IValueConverter +{ + /// + /// Converts indent level to Thickness. + /// + /// The indent level integer. + /// Target binding type. + /// Converter parameter. + /// Culture info. + /// A Thickness value for left margin indentation. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var indent = value is int level ? level : 0; + var indentPixels = Math.Min(indent, 5) * 24; + return new Thickness(indentPixels, 0, 0, 8); + } + + /// + /// Not supported for one-way conversion. + /// + /// The target value. + /// Target binding type. + /// Converter parameter. + /// Culture info. + /// Always throws NotSupportedException. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs b/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs new file mode 100644 index 000000000..e48a82f42 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs @@ -0,0 +1,38 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converter that returns true if the value is not equal to the parameter. +/// +internal sealed class NotEqualToConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null && parameter == null) + { + return false; + } + + if (value == null || parameter == null) + { + return true; + } + + return !value.Equals(parameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool b && !b) + { + return parameter; + } + + return Avalonia.Data.BindingOperations.DoNothing; + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs b/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs index 8856bb967..47b15602b 100644 --- a/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs @@ -53,12 +53,18 @@ public class StringToImageConverter : IValueConverter if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - // TODO: For web URLs, implement caching/downloading if needed + var cached = Services.ImageCacheService.Instance.GetBitmapFromMemory(path); + if (cached != null) + { + return cached; + } + + _ = Services.ImageCacheService.Instance.GetBitmapAsync(path); return null; } - // Handle local file paths - if (Path.IsPathRooted(path) && File.Exists(path)) + // Handle local file paths (reject UNC shares) + if (Path.IsPathRooted(path) && !path.StartsWith(@"\\", StringComparison.Ordinal) && !path.StartsWith("//", StringComparison.Ordinal) && File.Exists(path)) { return new Bitmap(path); } @@ -73,13 +79,13 @@ public class StringToImageConverter : IValueConverter } /// - /// Not implemented. Converts a Bitmap back to a string file path. + /// Not supported. Converts a Bitmap back to a string file path. /// /// - /// This method does not return a value; it always throws . - /// Always thrown as this converter only supports one-way conversion. + /// This method does not return a value; it always throws . + /// Always thrown as this converter only supports one-way conversion. public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { - throw new NotImplementedException(); + throw new NotSupportedException(); } } diff --git a/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs b/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs new file mode 100644 index 000000000..6870e1199 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Helpers; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a string containing HTML markup to clean, normalized plain text. +/// Optionally accepts a maximum length integer as parameter for single-line truncated conversion. +/// +public class StripHtmlConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string text) + { + return value; + } + + if (parameter is int maxLen) + { + return HtmlTextHelper.CleanToSingleLine(text, maxLen); + } + + if (parameter is string paramStr && int.TryParse(paramStr, CultureInfo.InvariantCulture, out var parsedMax)) + { + return HtmlTextHelper.CleanToSingleLine(text, parsedMax); + } + + return HtmlTextHelper.NormalizeHtml(text); + } + + /// + /// Always thrown as two-way binding is not supported. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs new file mode 100644 index 000000000..f447440fa --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using GenHub.Core.Constants; + +namespace GenHub.Infrastructure.Services; + +/// +/// Thread-safe service for downloading and caching web images in memory and on disk. +/// +public sealed class ImageCacheService +{ + private static readonly Lazy InstanceLazy = new(() => new ImageCacheService()); + private readonly LruMemoryCache memoryCache = new(ImageCacheConstants.MaxMemoryCacheEntries); + private readonly ConcurrentDictionary> pendingDownloads = new(StringComparer.OrdinalIgnoreCase); + private readonly HttpClient httpClient; + private readonly string cacheDirectory; + private readonly object diskCleanupLock = new(); + private DateTime lastDiskCleanup = DateTime.MinValue; + + /// + /// Gets the singleton instance of . + /// + public static ImageCacheService Instance => InstanceLazy.Value; + + private ImageCacheService() + { + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + ConnectTimeout = TimeSpan.FromSeconds(10), + }; + + httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(ImageCacheConstants.DefaultTimeoutSeconds), + }; + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); + Directory.CreateDirectory(cacheDirectory); + } + + /// + /// Validates whether an IP address is a safe public IP address (not loopback, private, link-local, carrier-grade NAT, or reserved). + /// + /// The IP address to evaluate. + /// if the IP address is safe; otherwise, . + public static bool IsSafeIpAddress(IPAddress ip) + { + if (IPAddress.IsLoopback(ip) || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) + { + return false; + } + + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + var bytes = ip.GetAddressBytes(); + if (bytes.Length == 4) + { + // 0.0.0.0/8 + if (bytes[0] == 0) return false; + + // 10.0.0.0/8 + if (bytes[0] == 10) return false; + + // 100.64.0.0/10 (Carrier-grade NAT) + if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127) return false; + + // 127.0.0.0/8 + if (bytes[0] == 127) return false; + + // 169.254.0.0/16 (Link-local) + if (bytes[0] == 169 && bytes[1] == 254) return false; + + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false; + + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return false; + } + else if (bytes.Length == 16) + { + // Unique local address (fc00::/7) + if ((bytes[0] & 0xfe) == 0xfc) return false; + + // Link-local address (fe80::/10) + if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false; + } + + return true; + } + + /// + /// Asynchronously validates that a host does not resolve to private or loopback IP addresses. + /// + /// The host name or IP string to evaluate. + /// Cancellation token. + /// if all resolved IP addresses are safe; otherwise, . + public static async Task IsSafeHostAsync(string host, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(host) || string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (IPAddress.TryParse(host, out var ip)) + { + return IsSafeIpAddress(ip); + } + + try + { + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + if (addresses.Length == 0) + { + return false; + } + + foreach (var addr in addresses) + { + if (!IsSafeIpAddress(addr)) + { + return false; + } + } + + return true; + } + catch + { + return false; + } + } + + /// + /// Validates whether a remote URL is a safe public HTTP or HTTPS endpoint. + /// Rejects local paths, UNC shares, loopback addresses, link-local addresses, and private networks. + /// + /// The URL string to evaluate. + /// When valid, receives the parsed . + /// if the URL meets the security criteria; otherwise, . + public static bool IsSafeRemoteUrl(string? url, out Uri? uri) + { + uri = null; + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsedUri)) + { + return false; + } + + if (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + + if (parsedUri.IsFile || parsedUri.IsUnc) + { + return false; + } + + var host = parsedUri.Host; + if (string.IsNullOrWhiteSpace(host) || + parsedUri.IsLoopback || + string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (IPAddress.TryParse(host, out var ip) && !IsSafeIpAddress(ip)) + { + return false; + } + + uri = parsedUri; + return true; + } + + /// + /// Synchronously checks if a bitmap is already cached in memory. + /// + /// The image URL. + /// The cached if present; otherwise, . + public Bitmap? GetBitmapFromMemory(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + return memoryCache.TryGet(url); + } + + /// + /// Asynchronously gets a bitmap from memory, disk cache, or web. + /// + /// The image URL. + /// Cancellation token. + /// The loaded , or if loading failed. + public async Task GetBitmapAsync(string url, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + var cached = memoryCache.TryGet(url); + if (cached != null) + { + return cached; + } + + // Handle avares:// URIs (embedded resources) + if (url.StartsWith("avares://", StringComparison.OrdinalIgnoreCase)) + { + try + { + var uri = new Uri(url); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Handle relative asset paths (e.g., "/Assets/Logos/logo.png") + if (url.StartsWith("/", StringComparison.Ordinal)) + { + try + { + var uri = new Uri($"avares://GenHub{url}"); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Handle asset paths starting with 'Assets/' + if (url.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + try + { + var uri = new Uri($"avares://GenHub/{url}"); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Validate safe remote HTTP/HTTPS endpoint. Untrusted local paths and UNC shares are rejected. + if (!IsSafeRemoteUrl(url, out _)) + { + return null; + } + + var diskPath = GetDiskCachePath(url); + if (File.Exists(diskPath)) + { + try + { + var diskBitmap = new Bitmap(diskPath); + memoryCache.AddOrUpdate(url, diskBitmap); + return diskBitmap; + } + catch + { + try + { + File.Delete(diskPath); + } + catch + { + // ignore file deletion failure + } + } + } + + var downloadTask = pendingDownloads.GetOrAdd(url, u => DownloadAndCacheAsync(u, diskPath)); + try + { + return await downloadTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return null; + } + } + + private async Task DownloadAndCacheAsync(string initialUrl, string diskPath) + { + try + { + var currentUrl = initialUrl; + HttpResponseMessage? response = null; + + for (int redirectCount = 0; redirectCount <= ImageCacheConstants.MaxRedirects; redirectCount++) + { + if (!IsSafeRemoteUrl(currentUrl, out var targetUri) || targetUri == null) + { + return null; + } + + var isSafeHost = await IsSafeHostAsync(targetUri.Host); + if (!isSafeHost) + { + return null; + } + + var request = new HttpRequestMessage(HttpMethod.Get, currentUrl); + request.Headers.Add("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"); + if (currentUrl.Contains("moddb.com", StringComparison.OrdinalIgnoreCase)) + { + request.Headers.Referrer = new Uri("https://www.moddb.com/"); + } + + response?.Dispose(); + response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if ((int)response.StatusCode >= 300 && (int)response.StatusCode <= 399) + { + var redirectLocation = response.Headers.Location; + if (redirectLocation == null) + { + return null; + } + + var nextUri = redirectLocation.IsAbsoluteUri + ? redirectLocation + : new Uri(targetUri, redirectLocation); + + currentUrl = nextUri.ToString(); + continue; + } + + break; + } + + if (response == null || !response.IsSuccessStatusCode) + { + response?.Dispose(); + return null; + } + + using (response) + { + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (!string.IsNullOrEmpty(mediaType) && + !mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && + !mediaType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (response.Content.Headers.ContentLength is long len && len > ImageCacheConstants.MaxImageDownloadSizeBytes) + { + return null; + } + + using var responseStream = await response.Content.ReadAsStreamAsync(); + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + long totalRead = 0; + int read = 0; + + while ((read = await responseStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + totalRead += read; + if (totalRead > ImageCacheConstants.MaxImageDownloadSizeBytes) + { + return null; + } + + await ms.WriteAsync(buffer.AsMemory(0, read)); + } + + if (ms.Length == 0) + { + return null; + } + + var bytes = ms.ToArray(); + await File.WriteAllBytesAsync(diskPath, bytes); + + using var decodeStream = new MemoryStream(bytes); + var bitmap = new Bitmap(decodeStream); + + memoryCache.AddOrUpdate(initialUrl, bitmap); + TriggerDiskCleanupIfNeeded(); + return bitmap; + } + } + catch + { + return null; + } + finally + { + pendingDownloads.TryRemove(initialUrl, out _); + } + } + + private string GetDiskCachePath(string url) + { + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(url)); + var sb = new StringBuilder(); + foreach (var b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + + return Path.Combine(cacheDirectory, sb.ToString() + ".img"); + } + + private void TriggerDiskCleanupIfNeeded() + { + if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + { + return; + } + + _ = Task.Run(() => + { + lock (diskCleanupLock) + { + if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + { + return; + } + + lastDiskCleanup = DateTime.UtcNow; + + try + { + if (!Directory.Exists(cacheDirectory)) + { + return; + } + + var di = new DirectoryInfo(cacheDirectory); + var files = di.GetFiles("*.img"); + var cutoff = DateTime.UtcNow.AddDays(-ImageCacheConstants.DiskCacheTtlDays); + long totalSize = 0; + + var fileList = new List(); + foreach (var file in files) + { + if (file.LastWriteTimeUtc < cutoff) + { + try + { + file.Delete(); + } + catch + { + // ignore cleanup failure + } + } + else + { + fileList.Add(file); + totalSize += file.Length; + } + } + + if (totalSize > ImageCacheConstants.MaxDiskCacheSizeBytes) + { + var sorted = fileList.OrderBy(f => f.LastWriteTimeUtc).ToList(); + foreach (var file in sorted) + { + if (totalSize <= ImageCacheConstants.MaxDiskCacheSizeBytes * 0.8) + { + break; + } + + try + { + totalSize -= file.Length; + file.Delete(); + } + catch + { + // ignore cleanup failure + } + } + } + } + catch + { + // ignore disk cleanup failures + } + } + }); + } + + /// + /// Thread-safe bounded LRU memory cache for bitmaps. + /// + private sealed class LruMemoryCache(int maxCapacity) + { + private readonly Dictionary> cache = new(StringComparer.OrdinalIgnoreCase); + private readonly LinkedList lruList = new(); + private readonly object syncLock = new(); + + public Bitmap? TryGet(string key) + { + lock (syncLock) + { + if (cache.TryGetValue(key, out var node)) + { + lruList.Remove(node); + lruList.AddFirst(node); + return node.Value.Bitmap; + } + + return null; + } + } + + public void AddOrUpdate(string key, Bitmap bitmap) + { + lock (syncLock) + { + if (cache.TryGetValue(key, out var existingNode)) + { + lruList.Remove(existingNode); + existingNode.Value = new CacheItem(key, bitmap); + lruList.AddFirst(existingNode); + } + else + { + if (cache.Count >= maxCapacity) + { + var last = lruList.Last; + if (last != null) + { + lruList.RemoveLast(); + cache.Remove(last.Value.Key); + } + } + + var node = new LinkedListNode(new CacheItem(key, bitmap)); + lruList.AddFirst(node); + cache[key] = node; + } + } + } + + public void Clear() + { + lock (syncLock) + { + cache.Clear(); + lruList.Clear(); + } + } + + private readonly record struct CacheItem(string Key, Bitmap Bitmap); + } +} diff --git a/scripts/build-check.ps1 b/scripts/build-check.ps1 new file mode 100644 index 000000000..445075c43 --- /dev/null +++ b/scripts/build-check.ps1 @@ -0,0 +1,249 @@ +<# +.SYNOPSIS + Serialized build/check script for GenHub. Prevents build conflicts when + multiple agents work simultaneously and avoids builds during debugging. + +.DESCRIPTION + Uses a named mutex to ensure only one build runs at a time. + Detects active debugger (devenv lock on output DLLs) and refuses to build. + Supports a lightweight "check" mode that only compiles without producing output. + +.PARAMETER Mode + "check" - Lightweight: compile-only, no output, fastest (default) + "build" - Full build with output + "restore" - NuGet restore only + +.PARAMETER Project + Specific .csproj to check. Defaults to the full solution. + Pass a project path relative to the GenHub solution folder for faster checks. + Example: "GenHub.Core/GenHub.Core.csproj" + +.PARAMETER TimeoutSeconds + Max seconds to wait for the build mutex. Default: 120 + +.PARAMETER Verbosity + MSBuild verbosity: quiet, minimal, normal, detailed. Default: quiet + +.EXAMPLE + # Quick error check on the full solution + .\scripts\build-check.ps1 + +.EXAMPLE + # Quick error check on a single project + .\scripts\build-check.ps1 -Project "GenHub.Core/GenHub.Core.csproj" + +.EXAMPLE + # Full build (serialized, safe) + .\scripts\build-check.ps1 -Mode build + +.EXAMPLE + # Check with longer timeout + .\scripts\build-check.ps1 -TimeoutSeconds 300 +#> + +param( + [ValidateSet("check", "build", "restore")] + [string]$Mode = "check", + + [string]$Project = "", + + [int]$TimeoutSeconds = 120, + + [ValidateSet("quiet", "minimal", "normal", "detailed")] + [string]$Verbosity = "quiet" +) + +$ErrorActionPreference = "Stop" + +# ── Constants ────────────────────────────────────────────────────────────────── +$MutexName = "Global\GenHub_Build_Mutex" +$SolutionDir = Join-Path (Join-Path $PSScriptRoot "..") "GenHub" +$SolutionFile = Join-Path $SolutionDir "GenHub.sln" +$LockFileName = "build.lock" +$LockFilePath = Join-Path $SolutionDir $LockFileName + +# ── Helper functions ─────────────────────────────────────────────────────────── + +function Write-Status { + param([string]$Message, [string]$Color = "Cyan") + Write-Host "[build-check] " -ForegroundColor DarkGray -NoNewline + Write-Host $Message -ForegroundColor $Color +} + +function Write-Err { + param([string]$Message) + Write-Host "[build-check] " -ForegroundColor DarkGray -NoNewline + Write-Host "ERROR: $Message" -ForegroundColor Red +} + +function Test-DebuggerActive { + <# + .SYNOPSIS + Detects if Visual Studio is debugging GenHub by checking for file locks + on the output DLLs in bin/Debug directories. + #> + + # Check for devenv.exe processes that hold locks + $devenvProcesses = Get-Process -Name "devenv" -ErrorAction SilentlyContinue + if (-not $devenvProcesses) { + return $false + } + + # Check if GenHub output DLLs are locked (indicates active debugging) + $binDebugDirs = Get-ChildItem -Path $SolutionDir -Directory -Recurse -Filter "Debug" | + Where-Object { $_.Parent.Name -eq "bin" } + + foreach ($dir in $binDebugDirs) { + $dlls = Get-ChildItem -Path $dir.FullName -Filter "GenHub*.dll" -ErrorAction SilentlyContinue + foreach ($dll in $dlls) { + try { + # Try to open exclusively - if it fails, the file is locked (debugger) + $stream = [System.IO.File]::Open($dll.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + $stream.Close() + $stream.Dispose() + } + catch { + # File is locked - debugger is likely active + return $true + } + } + } + + return $false +} + +function Get-BuildTarget { + if ($Project) { + $projectPath = Join-Path $SolutionDir $Project + if (-not (Test-Path $projectPath)) { + Write-Err "Project not found: $projectPath" + exit 1 + } + return $projectPath + } + return $SolutionFile +} + +# ── Pre-flight checks ───────────────────────────────────────────────────────── + +if (-not (Test-Path $SolutionFile)) { + Write-Err "Solution not found at: $SolutionFile" + exit 1 +} + +# Check for debugger +if (Test-DebuggerActive) { + Write-Err "Visual Studio debugger appears to be active (output DLLs are locked)." + Write-Err "Cannot build while debugging. Detach the debugger first." + exit 2 +} + +# ── Acquire mutex ────────────────────────────────────────────────────────────── + +$mutex = $null +$acquired = $false + +try { + Write-Status "Acquiring build lock (timeout: ${TimeoutSeconds}s)..." + + $mutex = [System.Threading.Mutex]::new($false, $MutexName) + try { + $acquired = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + } + catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + + if (-not $acquired) { + Write-Err "Timed out waiting for build lock after ${TimeoutSeconds}s." + Write-Err "Another agent or process is currently building." + exit 3 + } + + # Write lock file for visibility + $lockInfo = @{ + pid = $PID + mode = $Mode + project = if ($Project) { $Project } else { "GenHub.sln" } + startedAt = (Get-Date -Format "o") + agent = $env:AGENT_NAME + } | ConvertTo-Json -Compress + Set-Content -Path $LockFilePath -Value $lockInfo -Force + + Write-Status "Build lock acquired." "Green" + + # ── Execute build ────────────────────────────────────────────────────────── + + $target = Get-BuildTarget + $exitCode = 0 + + switch ($Mode) { + "check" { + Write-Status "Running compile check on: $(Split-Path $target -Leaf)" + + # Use --no-restore to skip package resolution (much faster) + # Use --no-dependencies when checking a single project (skip transitive) + $args = @( + "build", $target, + "--no-restore", + "--nologo", + "--verbosity", $Verbosity, + "-maxcpucount:2" + ) + + if ($Project) { + $args += "--no-dependencies" + } + + & dotnet @args + $exitCode = $LASTEXITCODE + } + + "build" { + Write-Status "Running full build on: $(Split-Path $target -Leaf)" + + $args = @( + "build", $target, + "--nologo", + "--verbosity", $Verbosity, + "-maxcpucount:2" + ) + + & dotnet @args + $exitCode = $LASTEXITCODE + } + + "restore" { + Write-Status "Running NuGet restore on: $(Split-Path $target -Leaf)" + + & dotnet restore $target --verbosity $Verbosity + $exitCode = $LASTEXITCODE + } + } + + # ── Report result ────────────────────────────────────────────────────────── + + if ($exitCode -eq 0) { + Write-Status "Completed successfully with no errors." "Green" + } + else { + Write-Err "Build/check failed with exit code: $exitCode" + } + + exit $exitCode +} +finally { + # Clean up lock file + if (Test-Path $LockFilePath) { + Remove-Item $LockFilePath -Force -ErrorAction SilentlyContinue + } + + # Release mutex + if ($acquired -and $mutex) { + $mutex.ReleaseMutex() + } + + if ($mutex) { + $mutex.Dispose() + } +} From ba457296c406a5ca5243a5c3896ee9201b2c1cbb Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:16:05 +0200 Subject: [PATCH 20/20] fix(imagecache): harden cache lifecycle, validate DNS, enforce TTL, and use SHA-256 key hashing --- .../Services/ImageCacheService.cs | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs index f447440fa..4ea7f00f7 100644 --- a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs +++ b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs @@ -41,6 +41,24 @@ private ImageCacheService() AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(5), ConnectTimeout = TimeSpan.FromSeconds(10), + ConnectCallback = async (context, cancellationToken) => + { + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, cancellationToken); + var safeIp = entry.AddressList.FirstOrDefault(IsSafeIpAddress) + ?? throw new HttpRequestException($"No safe IP address resolved for host '{context.DnsEndPoint.Host}'"); + + var socket = new System.Net.Sockets.Socket(safeIp.AddressFamily, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); + try + { + await socket.ConnectAsync(new IPEndPoint(safeIp, context.DnsEndPoint.Port), cancellationToken); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }, }; httpClient = new HttpClient(handler) @@ -50,9 +68,16 @@ private ImageCacheService() httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); - Directory.CreateDirectory(cacheDirectory); + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); + Directory.CreateDirectory(cacheDirectory); + } + catch + { + cacheDirectory = string.Empty; + } } /// @@ -304,15 +329,11 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } var diskPath = GetDiskCachePath(url); - if (File.Exists(diskPath)) + if (!string.IsNullOrEmpty(diskPath) && File.Exists(diskPath)) { - try - { - var diskBitmap = new Bitmap(diskPath); - memoryCache.AddOrUpdate(url, diskBitmap); - return diskBitmap; - } - catch + var fileInfo = new FileInfo(diskPath); + var cutoff = DateTime.UtcNow.AddDays(-ImageCacheConstants.DiskCacheTtlDays); + if (fileInfo.LastWriteTimeUtc < cutoff) { try { @@ -323,6 +344,26 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) // ignore file deletion failure } } + else + { + try + { + var diskBitmap = new Bitmap(diskPath); + memoryCache.AddOrUpdate(url, diskBitmap); + return diskBitmap; + } + catch + { + try + { + File.Delete(diskPath); + } + catch + { + // ignore file deletion failure + } + } + } } var downloadTask = pendingDownloads.GetOrAdd(url, u => DownloadAndCacheAsync(u, diskPath)); @@ -433,7 +474,10 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } var bytes = ms.ToArray(); - await File.WriteAllBytesAsync(diskPath, bytes); + if (!string.IsNullOrEmpty(diskPath)) + { + await File.WriteAllBytesAsync(diskPath, bytes); + } using var decodeStream = new MemoryStream(bytes); var bitmap = new Bitmap(decodeStream); @@ -449,14 +493,22 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } finally { - pendingDownloads.TryRemove(initialUrl, out _); + if (pendingDownloads.TryGetValue(initialUrl, out var task) && task.IsCompleted) + { + pendingDownloads.TryRemove(initialUrl, out _); + } } } private string GetDiskCachePath(string url) { - var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(url)); - var sb = new StringBuilder(); + if (string.IsNullOrEmpty(cacheDirectory)) + { + return string.Empty; + } + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(url)); + var sb = new StringBuilder(hashBytes.Length * 2); foreach (var b in hashBytes) { sb.Append(b.ToString("x2")); @@ -467,7 +519,7 @@ private string GetDiskCachePath(string url) private void TriggerDiskCleanupIfNeeded() { - if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + if (string.IsNullOrEmpty(cacheDirectory) || DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) { return; } @@ -528,8 +580,9 @@ private void TriggerDiskCleanupIfNeeded() try { - totalSize -= file.Length; + var fileLen = file.Length; file.Delete(); + totalSize -= fileLen; } catch { @@ -577,6 +630,11 @@ public void AddOrUpdate(string key, Bitmap bitmap) if (cache.TryGetValue(key, out var existingNode)) { lruList.Remove(existingNode); + if (!ReferenceEquals(existingNode.Value.Bitmap, bitmap)) + { + existingNode.Value.Bitmap.Dispose(); + } + existingNode.Value = new CacheItem(key, bitmap); lruList.AddFirst(existingNode); } @@ -589,6 +647,7 @@ public void AddOrUpdate(string key, Bitmap bitmap) { lruList.RemoveLast(); cache.Remove(last.Value.Key); + last.Value.Bitmap.Dispose(); } } @@ -603,6 +662,11 @@ public void Clear() { lock (syncLock) { + foreach (var item in lruList) + { + item.Bitmap.Dispose(); + } + cache.Clear(); lruList.Clear(); }