From 289d42722f02623b4b5acb7496708707dc6735fc Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:59:26 +0200 Subject: [PATCH 1/6] fix: enable executable selection for GameClient, ModdingTool, and Executable in Add Local Content dialog (#391) --- .../Content/ILocalContentService.cs | 8 +- .../Manifest/ManifestVariantResolver.cs | 8 +- .../Services/Content/LocalContentService.cs | 31 +- .../Services/LocalContentServiceTests.cs | 286 +++++++ .../AddLocalContentViewModelTests.cs | 799 ++++++++++++++++++ .../Services/ProfileLauncherFacade.cs | 297 ++++--- .../ViewModels/AddLocalContentViewModel.cs | 236 +++++- .../DemoAddLocalContentViewModel.cs | 1 + .../GameProfiles/ViewModels/FileTreeItem.cs | 12 +- .../GameProfileSettingsViewModel.Commands.cs | 4 +- .../Views/AddLocalContentView.axaml | 50 +- .../Views/AddLocalContentView.axaml.cs | 7 + .../Views/AddLocalContentWindow.axaml.cs | 7 + .../Info/Services/MockToolServices.cs | 29 +- .../ExecutableHighlightConverter.cs | 6 +- 15 files changed, 1611 insertions(+), 170 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs index 84a4d3773..9b26f26b2 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs @@ -20,6 +20,7 @@ public interface ILocalContentService /// Optional original source path of the content. /// Optional progress reporter for tracking manifest creation. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the created manifest or errors. Task> CreateLocalContentManifestAsync( string directoryPath, @@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Adds local content by creating and storing a manifest. @@ -66,6 +68,7 @@ Task> AddLocalContentAsync( /// Optional original source path of the content. /// Optional progress reporter. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the updated manifest. Task> UpdateLocalContentManifestAsync( string existingManifestId, @@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Gets the allowed content types for local content creation. diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs index ddb22794a..fc609db20 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs @@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint( files); } - private static bool PathsMatch(string left, string right) => + /// + /// Determines whether two relative file paths match, normalizing directory separators and leading slashes. + /// + /// The first relative path. + /// The second relative path. + /// true if the paths match; otherwise, false. + public static bool PathsMatch(string left, string right) => string.Equals( left.Replace('\\', '/').TrimStart('/'), right.Replace('\\', '/').TrimStart('/'), diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs index 8f544eec5..2912aa9a5 100644 --- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs +++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs @@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { @@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs var manifest = builder.Build(); manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath; + if (!string.IsNullOrWhiteSpace(entryPoint)) + { + var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/'); + + var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == "..")) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..')."); + } + + var matchedFile = manifest.Files.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint)); + + if (matchedFile == null) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' was not found among the files in the directory."); + } + + manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/'); + } + // Auto-add GameInstallation dependency for GameClient content types // This ensures auto-resolution logic works correctly for locally added clients if (contentType == ContentType.GameClient) @@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { // 1. Create the new manifest/content // We do this FIRST to ensure the new content is valid before deleting the old one - var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken); + var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint); if (!createResult.Success) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs new file mode 100644 index 000000000..d8f2d1692 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Services.Content; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services; + +/// +/// Contains tests for . +/// +public class LocalContentServiceTests : IDisposable +{ + private readonly Mock _manifestGenServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _reconciliationServiceMock; + private readonly LocalContentService _service; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public LocalContentServiceTests() + { + _manifestGenServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _reconciliationServiceMock = new Mock(); + + _service = new LocalContentService( + _manifestGenServiceMock.Object, + _contentStorageServiceMock.Object, + _reconciliationServiceMock.Object, + NullLogger.Instance); + + _tempDir = Path.Combine(Path.GetTempPath(), "LocalContentServiceTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up temporary resources. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup failures + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that CreateLocalContentManifestAsync sets EntryPoint when provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithEntryPoint_SetsManifestEntryPoint() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: "FinalBIG.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("FinalBIG.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync normalizes backslashes to forward slashes in EntryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_NormalizesBackslashesInEntryPoint() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "bin/sub/tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "bin\\sub\\tool.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("bin/sub/tool.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when passed a whitespace-only value. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithWhitespaceOnlyEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: " "); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects rooted or parent-traversal entry points. + /// + /// The invalid entry point path to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("/usr/bin/tool.exe")] + [InlineData("../tool.exe")] + [InlineData("bin/../../tool.exe")] + public async Task CreateLocalContentManifestAsync_WithInvalidEntryPointPath_ReturnsFailure(string invalidEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: invalidEntryPoint); + + Assert.False(result.Success); + Assert.Contains("invalid", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync accepts entry points with double dots in file or folder names. + /// + /// The valid entry point path with dots in name. + /// A task representing the asynchronous test. + [Theory] + [InlineData("game..exe")] + [InlineData("backup..old/tool.exe")] + public async Task CreateLocalContentManifestAsync_WithDoubleDotsInName_ReturnsSuccess(string validEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", validEntryPoint); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: validEntryPoint); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(validEntryPoint, result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects an entry point that does not exist in manifest files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithNonExistentEntryPoint_ReturnsFailure() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "missing.exe"); + + Assert.False(result.Success); + Assert.Contains("not found", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when not provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithoutEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.Mod, GameType.ZeroHour, "MyMod"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "MyMod", + contentType: ContentType.Mod, + targetGame: GameType.ZeroHour); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that UpdateLocalContentManifestAsync passes entryPoint through to the created manifest. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateLocalContentManifestAsync_WithEntryPoint_SetsEntryPointOnUpdatedManifest() + { + SetupManifestBuilder(ContentType.GameClient, GameType.ZeroHour, "GeneralsClient", "generals.exe"); + + _reconciliationServiceMock + .Setup(x => x.OrchestrateLocalUpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentUpdateResult())); + + var result = await _service.UpdateLocalContentManifestAsync( + existingManifestId: "1.0.local.gameclient.old", + name: "GeneralsClient", + directoryPath: _tempDir, + contentType: ContentType.GameClient, + targetGame: GameType.ZeroHour, + entryPoint: "generals.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("generals.exe", result.Data!.EntryPoint); + } + + private void SetupManifestBuilder(ContentType contentType, GameType targetGame, string contentName, params string[] filePaths) + { + var files = filePaths.Length > 0 + ? filePaths.Select(f => new ManifestFile { RelativePath = f, IsExecutable = f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) }).ToList() + : new List(); + + var manifest = new ContentManifest + { + Id = ManifestId.Create($"1.0.local.{contentType.ToString().ToLowerInvariant()}.{contentName.ToLowerInvariant()}"), + Name = contentName, + ContentType = contentType, + TargetGame = targetGame, + Files = files, + }; + + var builderMock = new Mock(); + builderMock.Setup(b => b.Build()).Returns(manifest); + + _manifestGenServiceMock + .Setup(x => x.CreateContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(builderMock.Object); + + _contentStorageServiceMock + .Setup(x => x.StoreContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs new file mode 100644 index 000000000..c7490ef60 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs @@ -0,0 +1,799 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.GameProfiles.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels; + +/// +/// Contains tests for . +/// +public class AddLocalContentViewModelTests : IDisposable +{ + private readonly Mock _localContentServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _normalizationServiceMock; + private readonly Mock _dialogServiceMock; + private readonly List _tempDirectories = []; + private readonly List _viewModels = []; + + /// + /// Initializes a new instance of the class. + /// + public AddLocalContentViewModelTests() + { + _localContentServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _normalizationServiceMock = new Mock(); + _dialogServiceMock = new Mock(); + + _localContentServiceMock + .Setup(x => x.AllowedContentTypes) + .Returns(AddLocalContentViewModel.AllowedContentTypes); + + _normalizationServiceMock + .Setup(x => x.DetectGenLauncherFilesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GenLauncherDetectionResult()); + } + + /// + /// Cleans up temporary test directories and viewmodels. + /// + public void Dispose() + { + foreach (var vm in _viewModels) + { + vm.Dispose(); + } + + foreach (var dir in _tempDirectories) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that the ViewModel initializes with proper defaults. + /// + [Fact] + public void Constructor_InitializesWithDefaultValues() + { + var vm = CreateViewModel(); + + Assert.NotNull(vm); + Assert.Equal(ContentType.Mod, vm.SelectedContentType); + Assert.Equal(GameType.ZeroHour, vm.SelectedGameType); + Assert.Empty(vm.ContentName); + Assert.Empty(vm.SourcePath); + Assert.Empty(vm.FileTree); + Assert.False(vm.IsEditing); + Assert.False(vm.CanAdd); + Assert.False(vm.ShowExecutableSelection); + Assert.Null(vm.SelectedExecutableItem); + Assert.Equal(0, vm.ExecutableCount); + Assert.Equal("Add Local Content", vm.DialogTitle); + Assert.Equal("Add to Library", vm.ActionButtonText); + Assert.Contains(ContentType.GameClient, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.ModdingTool, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.Executable, AddLocalContentViewModel.AllowedContentTypes); + } + + /// + /// Verifies that PreviewIdleText changes based on SelectedContentType. + /// + /// The content type under test. + /// The expected idle description text. + [Theory] + [InlineData(ContentType.Mod, "Import mod content (e.g. .big, .zip)")] + [InlineData(ContentType.GameClient, "Import GameClient")] + [InlineData(ContentType.Executable, "Import executable")] + [InlineData(ContentType.ModdingTool, "Import tool executable")] + [InlineData(ContentType.Patch, "Import patch")] + [InlineData(ContentType.Addon, "Import addon content")] + [InlineData(ContentType.Map, "Import map files")] + [InlineData(ContentType.MapPack, "Import map pack files")] + [InlineData(ContentType.Mission, "Import mission content")] + public void PreviewIdleText_ReturnsExpectedDescriptions(ContentType type, string expectedText) + { + var vm = CreateViewModel(); + vm.SelectedContentType = type; + + Assert.Equal(expectedText, vm.PreviewIdleText); + } + + /// + /// Verifies that ShowExecutableSelection is true when ExecutableCount > 0 for GameClient, ModdingTool, and Executable. + /// + /// The content type under test. + /// The number of detected executables. + /// The expected boolean indicating whether executable selection is shown. + [Theory] + [InlineData(ContentType.GameClient, 1, true)] + [InlineData(ContentType.GameClient, 2, true)] + [InlineData(ContentType.ModdingTool, 1, true)] + [InlineData(ContentType.ModdingTool, 2, true)] + [InlineData(ContentType.Executable, 1, true)] + [InlineData(ContentType.Executable, 2, true)] + [InlineData(ContentType.GameClient, 0, false)] + [InlineData(ContentType.ModdingTool, 0, false)] + [InlineData(ContentType.Executable, 0, false)] + [InlineData(ContentType.Mod, 1, false)] + [InlineData(ContentType.Mod, 2, false)] + [InlineData(ContentType.Patch, 1, false)] + [InlineData(ContentType.Map, 1, false)] + public void ShowExecutableSelection_EvaluatesCorrectly_BasedOnContentTypeAndExecutableCount( + ContentType contentType, + int executableCount, + bool expectedShow) + { + var vm = CreateViewModel(); + vm.SelectedContentType = contentType; + vm.ExecutableCount = executableCount; + + Assert.Equal(expectedShow, vm.ShowExecutableSelection); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for GameClient. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForGameClient_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "generals.exe"); + var dataPath = Path.Combine(tempDir, "data.ini"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "fake-data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("generals.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for ModdingTool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForModdingTool_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "FinalBIG.exe"); + var dataPath = Path.Combine(tempDir, "readme.txt"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "read me"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("FinalBIG.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for Executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForExecutable_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "WorldBuilder.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("WorldBuilder.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that switching to an executable content type triggers auto-selection if an executable is in the tree. + /// + /// The executable content type to switch to. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task SelectedContentTypeChanged_ToExecutableType_AutoSelectsFirstExecutable(ContentType newType) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Launcher.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + + await vm.ImportContentAsync(tempDir); + + // When imported as Mod, no auto-selection happened + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.ShowExecutableSelection); + + // Switch to executable type + vm.SelectedContentType = newType; + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("Launcher.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + Assert.True(vm.ShowExecutableSelection); + } + + /// + /// Verifies manual selection of an executable via SelectExecutableCommand. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_SwitchesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + var exe1Path = Path.Combine(tempDir, "Primary.exe"); + var exe2Path = Path.Combine(tempDir, "Secondary.exe"); + File.WriteAllText(exe1Path, "fake-exe-1"); + File.WriteAllText(exe2Path, "fake-exe-2"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(2, vm.ExecutableCount); + Assert.NotNull(vm.SelectedExecutableItem); + + var initialSelected = vm.SelectedExecutableItem!; + var otherItem = FindInTree(vm.FileTree, f => f != initialSelected && f.IsExecutable); + Assert.NotNull(otherItem); + Assert.False(otherItem!.IsSelectedExecutable); + + // Select the other executable + vm.SelectExecutableCommand.Execute(otherItem); + + Assert.Equal(otherItem.Name, vm.SelectedExecutableItem.Name); + Assert.True(otherItem.IsSelectedExecutable); + Assert.False(initialSelected.IsSelectedExecutable); + } + + /// + /// Verifies that SelectExecutableCommand ignores non-executable files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_IgnoresNonExecutableItem() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Tool.exe"); + var txtPath = Path.Combine(tempDir, "Doc.txt"); + File.WriteAllText(exePath, "fake-exe"); + File.WriteAllText(txtPath, "text"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + + var txtItem = FindInTree(vm.FileTree, f => f.Name == "Doc.txt"); + Assert.NotNull(txtItem); + Assert.False(txtItem!.IsExecutable); + + vm.SelectExecutableCommand.Execute(txtItem); + + // Should still be Tool.exe + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + Assert.False(txtItem.IsSelectedExecutable); + } + + /// + /// Verifies that CanAdd validation requires an executable for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_RequiresExecutable_ForExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "config.ini"); + File.WriteAllText(txtPath, "config"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Tool"; + + await vm.ImportContentAsync(tempDir); + + // No executable found, so CanAdd should be false + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true for non-executable types without an executable. + /// + /// The non-executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.Mod)] + [InlineData(ContentType.Patch)] + [InlineData(ContentType.Addon)] + [InlineData(ContentType.Map)] + [InlineData(ContentType.MapPack)] + [InlineData(ContentType.Mission)] + public async Task Validation_CanAdd_DoesNotRequireExecutable_ForNonExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "mod_data.big"); + File.WriteAllText(txtPath, "big archive data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Mod"; + + await vm.ImportContentAsync(tempDir); + + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true when an executable is present for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_IsTrue_WhenExecutableIsPresent(ContentType type) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Main.exe"); + File.WriteAllText(exePath, "exe content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Item"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that AddContentCommand forwards the relative entry point to ILocalContentService.CreateLocalContentManifestAsync. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_PassesEntryPoint_ToCreateLocalContentManifestAsync() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Game.exe"); + File.WriteAllText(exePath, "exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.gameclient.test"), + Name = "Test Game Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "Game.exe", + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Game Client"; + + // Import individual file so it lands at the root of staging + await vm.ImportContentAsync(exePath); + + Assert.True(vm.CanAdd); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Equal("Game.exe", capturedEntryPoint); + Assert.NotNull(vm.CreatedContentItem); + } + + /// + /// Verifies that AddContentCommand with nested executable passes correct relative path as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WithNestedExecutable_PassesRelativePathEntryPoint() + { + var tempDir = CreateTempDirectory(); + var subDir = Path.Combine(tempDir, "bin"); + Directory.CreateDirectory(subDir); + var exePath = Path.Combine(subDir, "tool.exe"); + File.WriteAllText(exePath, "tool exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.moddingtool.tool"), + Name = "My Tool", + ContentType = ContentType.ModdingTool, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + vm.ContentName = "My Tool"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + await vm.AddContentCommand.ExecuteAsync(null); + + var dirName = Path.GetFileName(tempDir); + Assert.Equal($"{dirName}/bin/tool.exe", capturedEntryPoint); + } + + /// + /// Verifies that LoadFromManifestAsync preserves the manifest EntryPoint when reloading for edit. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task LoadFromManifestAsync_PreservesManifestEntryPoint() + { + var manifestId = ManifestId.Create("1.0.local.gameclient.zh"); + + var manifest = new ContentManifest + { + Id = manifestId, + Name = "ZH Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "special.exe", + Files = + [ + new ManifestFile { RelativePath = "special.exe", IsExecutable = true }, + new ManifestFile { RelativePath = "bin/decoy.exe", IsExecutable = true }, + ], + }; + + _contentStorageServiceMock + .Setup(x => x.RetrieveContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, targetPath, _) => + { + Directory.CreateDirectory(targetPath); + File.WriteAllText(Path.Combine(targetPath, "special.exe"), "exe"); + var targetSub = Path.Combine(targetPath, "bin"); + Directory.CreateDirectory(targetSub); + File.WriteAllText(Path.Combine(targetSub, "decoy.exe"), "decoy"); + }) + .ReturnsAsync((ManifestId _, string targetPath, CancellationToken _) => OperationResult.CreateSuccess(targetPath)); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.UpdateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var item = new GenHub.Features.GameProfiles.ViewModels.ContentDisplayItem + { + Id = manifestId.Value, + ManifestId = manifestId, + DisplayName = "ZH Client", + ContentType = ContentType.GameClient, + GameType = GameType.ZeroHour, + InstallationType = GameInstallationType.Unknown, + Manifest = manifest, + }; + + var vm = CreateViewModel(); + await vm.LoadFromManifestAsync(item); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("special.exe", vm.SelectedExecutableItem.Name); + + await vm.AddContentCommand.ExecuteAsync(null); + Assert.Equal("special.exe", capturedEntryPoint); + } + + /// + /// Verifies that deleting an unrelated item preserves the previously selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + var readme = FindInTree(vm.FileTree, f => f.Name == "readme.txt"); + Assert.NotNull(readme); + await vm.DeleteItemCommand.ExecuteAsync(readme); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that deleting the currently selected executable falls back to auto-selecting the remaining executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_WhenSelectedExecutableDeleted_FallsBackToRemainingExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + await vm.DeleteItemCommand.ExecuteAsync(secondExe); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("first.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that switching content type away from executable and back preserves the selected entry point. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_SwitchAwayAndBack_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + // Switch to Mod (non-executable type) + vm.SelectedContentType = ContentType.Mod; + Assert.Null(vm.SelectedExecutableItem); + + // Switch back to GameClient (executable type) + vm.SelectedContentType = ContentType.GameClient; + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that BuildDirectoryTree prioritizes directories containing executables over non-executable directories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task BuildDirectoryTree_PrioritizesDirectoriesWithExecutables() + { + var tempDir = CreateTempDirectory(); + + // Create 25 directories named folder01 to folder25 + for (var i = 1; i <= 25; i++) + { + var folder = Path.Combine(tempDir, $"folder{i:D2}"); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "data.txt"), "content"); + } + + // Put an executable only in the 25th folder + var targetFolder = Path.Combine(tempDir, "folder25"); + File.WriteAllText(Path.Combine(targetFolder, "game.exe"), "executable"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var folder25 = FindInTree(vm.FileTree, f => f.Name == "folder25"); + Assert.NotNull(folder25); + + var exe = FindInTree(folder25.Children, f => f.Name == "game.exe"); + Assert.NotNull(exe); + Assert.True(exe.IsExecutable); + } + + /// + /// Verifies that switching from an executable type to a non-executable type clears the selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_FromExecutableToNonExecutable_ClearsSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "game.exe"), "game"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + vm.SelectedContentType = ContentType.Mod; + + Assert.Null(vm.SelectedExecutableItem); + } + + /// + /// Verifies that AddContentCommand with non-executable content type passes null as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WhenNonExecutableType_PassesNullEntryPoint() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "somefile.txt"), "text"); + + string? capturedEntryPoint = "INITIAL"; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.mod.test"), + Name = "My Mod", + ContentType = ContentType.Mod, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + vm.ContentName = "My Mod"; + await vm.ImportContentAsync(tempDir); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Null(capturedEntryPoint); + } + + private static FileTreeItem? FindInTree(IEnumerable items, Func predicate) + { + foreach (var item in items) + { + if (predicate(item)) return item; + var child = FindInTree(item.Children, predicate); + if (child != null) return child; + } + + return null; + } + + private string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "AddLocalContentVmTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + _tempDirectories.Add(path); + return path; + } + + private AddLocalContentViewModel CreateViewModel() + { + var vm = new AddLocalContentViewModel( + _localContentServiceMock.Object, + _contentStorageServiceMock.Object, + _normalizationServiceMock.Object, + _dialogServiceMock.Object, + NullLogger.Instance); + _viewModels.Add(vm); + return vm; + } +} diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 2ffca7e4c..7e36d6669 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs @@ -451,99 +451,25 @@ private async Task> LaunchToolProfileAsyn { logger.LogInformation("[Launch] Detected Tool profile, launching tool directly"); - // Get the tool manifest - if (string.IsNullOrWhiteSpace(profile.ToolContentId)) - { - return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); - } - - if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); - } - - var toolManifestResult = await manifestPool.GetManifestAsync( - toolManifestId, - cancellationToken); - - if (toolManifestResult.Failed || toolManifestResult.Data == null) + var manifestResult = await ResolveToolManifestAsync(profile, cancellationToken); + if (manifestResult.Failed || manifestResult.Data == null) { return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + manifestResult.FirstError ?? ProfileValidationConstants.FailedToLoadToolManifest); } - var toolManifest = toolManifestResult.Data; + var toolManifest = manifestResult.Data; logger.LogDebug("[Launch] Tool manifest loaded: {ManifestId}", toolManifest.Id); - var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); - string toolWorkspacePath = string.Empty; - string? actualWorkspaceId = null; - - if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + var workspaceResult = await ResolveToolWorkspaceAsync(profile, toolManifest, cancellationToken); + if (workspaceResult.Failed) { - toolWorkspacePath = toolDirectory.Data; - logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolWorkspacePath); - } - else - { - logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); - - var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient - { - Name = toolManifest.Name, - GameType = toolManifest.TargetGame, - }; - - var appDataBase = configurationProvider.GetApplicationDataPath(); - if (!Directory.Exists(appDataBase)) - { - Directory.CreateDirectory(appDataBase); - } - - var baseDetails = appDataBase; - - var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); - var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; - - var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); - var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); - - if (effectiveToolStrategy != requestedToolStrategy) - { - logger.LogInformation( - "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", - requestedToolStrategy); - } - - actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; - var workspaceConfig = new WorkspaceConfiguration - { - Id = actualWorkspaceId, - Manifests = [.. allManifests], - GameClient = dummyGameClient, - Strategy = effectiveToolStrategy, - ForceRecreate = false, - ValidateAfterPreparation = true, - BaseInstallationPath = baseDetails, - WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), - SkipCleanup = false, - }; - - var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); - if (prepareResult.Failed) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); - } - - toolWorkspacePath = prepareResult.Data!.WorkspacePath; - logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult.CreateFailure( + workspaceResult.FirstError ?? ProfileValidationConstants.FailedToPrepareToolWorkspace); } - var toolDirectoryPath = toolWorkspacePath; - var toolExecutable = toolManifest.Files?.FirstOrDefault(f => f.IsExecutable) - ?? toolManifest.Files?.FirstOrDefault(f => f.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + var (toolDirectoryPath, actualWorkspaceId) = workspaceResult.Data; + var toolExecutable = ResolveToolExecutable(toolManifest); if (toolExecutable == null) { @@ -564,35 +490,7 @@ private async Task> LaunchToolProfileAsyn try { - var processStartInfo = new ProcessStartInfo - { - FileName = toolExecutablePath, - WorkingDirectory = toolDirectoryPath, - Arguments = profile.CommandLineArguments ?? string.Empty, - UseShellExecute = false, - }; - - if (profile.EnvironmentVariables != null) - { - foreach (var envVar in profile.EnvironmentVariables) - { - processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; - } - } - - Process? process = null; - try - { - process = Process.Start(processStartInfo); - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) - { - logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); - processStartInfo.UseShellExecute = true; - processStartInfo.Verb = "runas"; - process = Process.Start(processStartInfo); - } - + var process = StartToolProcess(toolExecutablePath, toolDirectoryPath, profile); if (process == null) { return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProcessStartFailed); @@ -631,12 +529,181 @@ private async Task> LaunchToolProfileAsyn } catch (Exception ex) { - logger.LogError(ex, "[Launch] Tool launch failed"); + logger.LogError(ex, "[Launch] Unexpected error launching tool for profile {ProfileId}", profileId); notificationService.ShowError( ProfileValidationConstants.ToolLaunchFailedTitle, $"Failed to launch '{profile.Name}': {ex.Message}", NotificationDurations.VeryLong); - return ProfileOperationResult.CreateFailure($"Tool launch failed: {ex.Message}"); + return ProfileOperationResult.CreateFailure( + $"Tool launch failed: {ex.Message}"); + } + } + + private async Task> ResolveToolManifestAsync( + GameProfile profile, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(profile.ToolContentId)) + { + return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); + } + + if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); + } + + var toolManifestResult = await manifestPool.GetManifestAsync( + toolManifestId, + cancellationToken); + + if (toolManifestResult.Failed || toolManifestResult.Data == null) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + } + + return ProfileOperationResult.CreateSuccess(toolManifestResult.Data); + } + + private async Task> ResolveToolWorkspaceAsync( + GameProfile profile, + ContentManifest toolManifest, + CancellationToken cancellationToken) + { + var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); + if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + { + logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolDirectory.Data); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolDirectory.Data, null)); + } + + logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); + + var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient + { + Name = toolManifest.Name, + GameType = toolManifest.TargetGame, + }; + + var appDataBase = configurationProvider.GetApplicationDataPath(); + if (!Directory.Exists(appDataBase)) + { + Directory.CreateDirectory(appDataBase); + } + + var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); + var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; + + var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); + var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); + + if (effectiveToolStrategy != requestedToolStrategy) + { + logger.LogInformation( + "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", + requestedToolStrategy); + } + + var actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; + var workspaceConfig = new WorkspaceConfiguration + { + Id = actualWorkspaceId, + Manifests = [.. allManifests], + GameClient = dummyGameClient, + Strategy = effectiveToolStrategy, + ForceRecreate = false, + ValidateAfterPreparation = true, + BaseInstallationPath = appDataBase, + WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), + SkipCleanup = false, + }; + + var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); + if (prepareResult.Failed) + { + return ProfileOperationResult<(string, string?)>.CreateFailure( + $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); + } + + var toolWorkspacePath = prepareResult.Data!.WorkspacePath; + logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolWorkspacePath, actualWorkspaceId)); + } + + private ManifestFile? ResolveToolExecutable(ContentManifest toolManifest) + { + var resolvedFiles = ManifestVariantResolver.ResolveFiles(toolManifest); + var resolution = ManifestVariantResolver.ResolveEntryPoint(toolManifest); + + if (resolution.Success && resolution.RelativePath != null) + { + var toolExecutable = resolvedFiles?.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, resolution.RelativePath)); + + if (toolExecutable != null) + { + logger.LogInformation( + "[Launch] Tool executable resolved for manifest {ManifestId}: {RelativePath} ({Reason})", + toolManifest.Id, + toolExecutable.RelativePath, + resolution.Reason); + } + else + { + logger.LogWarning( + "[Launch] Entry point '{RelativePath}' resolved for tool manifest {ManifestId} ({Reason}) but not found in resolved files", + resolution.RelativePath, + toolManifest.Id, + resolution.Reason); + } + + return toolExecutable; + } + + logger.LogWarning( + "[Launch] Entry point resolution for tool manifest '{ManifestId}' did not succeed: {Resolution}", + toolManifest.Id, + resolution); + + return null; + } + + private Process? StartToolProcess(string toolExecutablePath, string toolDirectoryPath, GameProfile profile) + { + var processStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = false, + }; + + if (profile.EnvironmentVariables != null) + { + foreach (var envVar in profile.EnvironmentVariables) + { + processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; + } + } + + try + { + return Process.Start(processStartInfo); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) + { + logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); + var elevatedStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = true, + Verb = "runas", + }; + return Process.Start(elevatedStartInfo); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs index 55a28b999..386f01d9b 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs @@ -12,6 +12,8 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -29,7 +31,7 @@ public partial class AddLocalContentViewModel( IContentStorageService? contentStorageService, IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, - ILogger? logger = null) : ObservableObject + ILogger? logger = null) : ObservableObject, IDisposable { /// /// Gets the list of available game types. @@ -56,6 +58,26 @@ public partial class AddLocalContentViewModel( ContentType.Mission, ]; + /// + /// Counts the total number of executables in the given file tree items recursively. + /// + /// The file tree items to inspect. + /// The total number of executable files found. + internal static int CountExecutables(IEnumerable items) + { + int count = 0; + foreach (var item in items) + { + if (item.IsExecutable) count++; + count += CountExecutables(item.Children); + } + + return count; + } + + private static bool RequiresExecutable(ContentType contentType) => + contentType is ContentType.GameClient or ContentType.ModdingTool or ContentType.Executable; + private static FileTreeItem? FindFirstExecutable(IEnumerable items) { foreach (var item in items) @@ -75,21 +97,10 @@ public partial class AddLocalContentViewModel( return null; } - private static int CountExecutables(IEnumerable items) - { - int count = 0; - foreach (var item in items) - { - if (item.IsExecutable) count++; - count += CountExecutables(item.Children); - } - - return count; - } - private readonly string _stagingPath = Path.Combine(Path.GetTempPath(), "GenHub_Staging_" + Guid.NewGuid()); private string? _originalManifestId; + private string? _pendingEntryPoint; /// /// Gets a value indicating whether we are editing existing content. @@ -177,7 +188,7 @@ private static int CountExecutables(IEnumerable items) private bool _isDemoMode; /// - /// Gets or sets the selected executable item (for Executable/ModdingTool content type). + /// Gets or sets the selected executable item (for GameClient/Executable/ModdingTool content type). /// [ObservableProperty] private FileTreeItem? _selectedExecutableItem; @@ -192,7 +203,7 @@ private static int CountExecutables(IEnumerable items) /// /// Gets a value indicating whether the executable selection should be shown. /// - public bool ShowExecutableSelection => (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) && ExecutableCount > 1; + public bool ShowExecutableSelection => RequiresExecutable(SelectedContentType) && ExecutableCount > 0; /// /// Gets the text to display in the preview area when no content is loaded. @@ -255,6 +266,7 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item) StatusMessage = "Loading existing content..."; _originalManifestId = item.ManifestId.Value; + _pendingEntryPoint = item.Manifest?.EntryPoint; ContentName = item.DisplayName ?? string.Empty; SelectedContentType = item.ContentType; SelectedGameType = item.GameType; @@ -487,24 +499,81 @@ public async Task ImportContentAsync(string path) } } + /// + public void Dispose() + { + _cts?.Dispose(); + _cts = null; + CleanupStaging(); + GC.SuppressFinalize(this); + } + private static List BuildDirectoryTree(DirectoryInfo dir) + => BuildDirectoryTree(dir, CollectExecutableDirectories(dir)); + + private static HashSet CollectExecutableDirectories(DirectoryInfo root) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var file in root.EnumerateFiles("*", SearchOption.AllDirectories)) + { + if (!ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(file.Name) + && !file.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var d = file.Directory; d != null; d = d.Parent) + { + if (!result.Add(d.FullName)) + { + break; + } + } + } + } + catch + { + // ignore inaccessible directories + } + + return result; + } + + private static List BuildDirectoryTree(DirectoryInfo dir, HashSet executableDirs) { var items = new List(); - if (!dir.Exists) return items; + if (!dir.Exists) + { + return items; + } + + var subDirs = dir.GetDirectories(); + var prioritizedDirs = subDirs + .OrderByDescending(d => executableDirs.Contains(d.FullName)) + .ThenBy(d => d.Name) + .Take(20); - foreach (var d in dir.GetDirectories().Take(20)) + foreach (var d in prioritizedDirs) { items.Add(new FileTreeItem { Name = d.Name, IsFile = false, FullPath = d.FullName, - Children = new ObservableCollection(BuildDirectoryTree(d)), + Children = new ObservableCollection(BuildDirectoryTree(d, executableDirs)), }); } - foreach (var f in dir.GetFiles().Take(50)) + var files = dir.GetFiles(); + var prioritizedFiles = files + .OrderByDescending(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.Name) || f.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + .ThenBy(f => f.Name) + .Take(50); + + foreach (var f in prioritizedFiles) { items.Add(new FileTreeItem { Name = f.Name, IsFile = true, FullPath = f.FullName }); } @@ -640,6 +709,20 @@ private async Task AddContentAsync() _cts = new CancellationTokenSource(); + string? entryPoint = null; + if (RequiresExecutable(SelectedContentType) && SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + entryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to determine relative path for selected executable '{FullPath}'. Falling back to file name '{Name}'", SelectedExecutableItem.FullPath, SelectedExecutableItem.Name); + entryPoint = SelectedExecutableItem.Name; + } + } + // Preserve SourcePath metadata if available // Note: We no longer write to "source.path" file to avoid polluting the content. // Instead we pass the SourcePath directly to the service. @@ -652,7 +735,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token) + _cts.Token, + entryPoint) : await localContentService.CreateLocalContentManifestAsync( _stagingPath, ContentName, @@ -660,7 +744,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token); + _cts.Token, + entryPoint); if (result.Success) { @@ -770,6 +855,29 @@ private void CreateMapFoldersIfNeeded() } } + private FileTreeItem? FindFileItemByRelativePath(IEnumerable items, string relativePath) + { + var normalizedTarget = relativePath.Replace('\\', '/').TrimStart('/'); + foreach (var item in items) + { + if (item.IsFile) + { + var itemRel = Path.GetRelativePath(_stagingPath, item.FullPath).Replace('\\', '/').TrimStart('/'); + if (ManifestVariantResolver.PathsMatch(itemRel, normalizedTarget)) + { + return item; + } + } + else + { + var found = FindFileItemByRelativePath(item.Children, relativePath); + if (found != null) return found; + } + } + + return null; + } + private async Task RefreshStagingTreeAsync() { bool wasBusy = IsBusy; @@ -777,6 +885,23 @@ private async Task RefreshStagingTreeAsync() { if (!wasBusy) IsBusy = true; + string? previousRelativePath = null; + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + previousRelativePath = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + else if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + previousRelativePath = _pendingEntryPoint; + } + FileTree.Clear(); SelectedExecutableItem = null; // Clear previous selection on refresh if (Directory.Exists(_stagingPath)) @@ -791,10 +916,29 @@ private async Task RefreshStagingTreeAsync() ExecutableCount = CountExecutables(FileTree); - // Auto-select first executable if content type requires it - if (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) + // Reselect previously selected executable or auto-select first if content type requires it + if (RequiresExecutable(SelectedContentType)) { - AutoSelectFirstExecutable(); + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(previousRelativePath)) + { + matchedItem = FindFileItemByRelativePath(FileTree, previousRelativePath); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + _pendingEntryPoint = null; + AutoSelectFirstExecutable(); + } + } + else + { + SelectedExecutableItem = null; } Validate(); @@ -816,8 +960,8 @@ private void Validate() var stagingExists = Directory.Exists(_stagingPath); var stagingHasEntries = stagingExists && Directory.EnumerateFileSystemEntries(_stagingPath).Any(); - // For ModdingTool (Tool) and Executable, we also need an executable selected - var requiresExecutable = SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable; + // For GameClient, ModdingTool (Tool), and Executable, we also need an executable selected + var requiresExecutable = RequiresExecutable(SelectedContentType); var hasExecutableIfNeeded = !requiresExecutable || SelectedExecutableItem != null; CanAdd = hasName && (hasFiles || stagingHasEntries) && hasExecutableIfNeeded; @@ -842,10 +986,44 @@ partial void OnSelectedContentTypeChanged(ContentType value) OnPropertyChanged(nameof(ShowExecutableSelection)); OnPropertyChanged(nameof(PreviewIdleText)); - // Auto-select first executable if switching to ModdingTool or Executable - if ((value == ContentType.ModdingTool || value == ContentType.Executable) && SelectedExecutableItem == null) + // Auto-select first executable if switching to a content type that requires it, + // or clear selection when switching to a non-executable content type + if (RequiresExecutable(value)) + { + if (SelectedExecutableItem == null) + { + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + matchedItem = FindFileItemByRelativePath(FileTree, _pendingEntryPoint); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + AutoSelectFirstExecutable(); + } + } + } + else { - AutoSelectFirstExecutable(); + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + _pendingEntryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + + SelectedExecutableItem = null; } Validate(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs index afbd8961b..a22b52d3d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs @@ -147,6 +147,7 @@ private void InitializeDemoData() }; FileTree.Add(modFolder); + ExecutableCount = CountExecutables(FileTree); // Set status message StatusMessage = "Demo content ready. Click buttons to see what they do!"; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs index 20b12e965..81052c517 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs @@ -14,17 +14,23 @@ public partial class FileTreeItem : ObservableObject /// /// Gets or sets the name of the file or directory. /// - public string Name { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _name = string.Empty; /// /// Gets or sets a value indicating whether this item is a file. /// - public bool IsFile { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private bool _isFile; /// /// Gets or sets the full path of the file or directory. /// - public string FullPath { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _fullPath = string.Empty; /// /// Gets or sets the children of this item (for directories). diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index ae01bb830..fd7e36b13 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -699,7 +699,7 @@ private async Task AddLocalContentAsync(Avalonia.Controls.Window? owner) if (dialogOwner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, @@ -767,7 +767,7 @@ private async Task EditContentAsync(ContentDisplayItem? contentItem) if (owner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 081789f27..d9256dfae 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -13,6 +13,7 @@ + @@ -115,7 +116,13 @@ + Classes="glass"> + + + + + + @@ -157,7 +164,7 @@ - + - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + 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 @@