From bc0f00117ccb43adf88b45c10cb5008c47fe3a46 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Sat, 15 Aug 2026 04:47:04 +0200 Subject: [PATCH 01/44] feat(gameprofiles): enable runtime content hot-swapping for active game sessions --- .../Workspace/ContentHotswapClassification.cs | 36 ++ .../GameProfileManagerHotswapTests.cs | 301 +++++++++++++++ ...ameProfileSettingsViewModelHotswapTests.cs | 345 ++++++++++++++++++ ...ontentReconciliationServiceHotswapTests.cs | 150 ++++++++ .../ContentHotswapClassificationTests.cs | 68 ++++ .../Services/ContentReconciliationService.cs | 35 +- .../Services/GameProfileManager.cs | 73 +++- .../ViewModels/GameProfileItemViewModel.cs | 4 +- .../GameProfileSettingsViewModel.Commands.cs | 31 ++ ...ProfileSettingsViewModel.Initialization.cs | 12 + ...GameProfileSettingsViewModel.Properties.cs | 9 + .../GameProfileSettingsViewModel.cs | 43 ++- .../GameProfileGeneralSettingsView.axaml | 2 +- .../GameProfileSettingsContentView.axaml | 1 + .../Views/GameProfileSettingsWindow.axaml | 23 +- 15 files changed, 1117 insertions(+), 16 deletions(-) create mode 100644 GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs diff --git a/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs new file mode 100644 index 000000000..e63372458 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs @@ -0,0 +1,36 @@ +using GenHub.Core.Models.Enums; + +namespace GenHub.Core.Models.Workspace; + +/// +/// Classifies content types based on whether they can be safely hot-swapped during an active game session. +/// Hotswappable content is deployed to the user Documents directory and read dynamically by the game engine. +/// Locked content modifies game process executables, BIG archives in the workspace, or memory-sensitive assets. +/// +public static class ContentHotswapClassification +{ + /// + /// Determines whether the specified content type can be hot-swapped while the game is running. + /// + /// The content type to evaluate. + /// true if the content type is hotswappable; otherwise, false. + public static bool IsHotswappable(ContentType contentType) + { + return contentType switch + { + ContentType.Map => true, + ContentType.MapPack => true, + _ => false, + }; + } + + /// + /// Determines whether the specified content type is locked and cannot be modified during an active game session. + /// + /// The content type to evaluate. + /// true if the content type is locked during active sessions; otherwise, false. + public static bool IsLocked(ContentType contentType) + { + return !IsHotswappable(contentType); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs new file mode 100644 index 000000000..2c31a806d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.GameProfiles.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; +using WorkspaceStrategy = GenHub.Core.Models.Enums.WorkspaceStrategy; + +namespace GenHub.Tests.Core.Features.GameProfiles.Services; + +/// +/// Unit tests for runtime content hot-swapping validation in . +/// +public class GameProfileManagerHotswapTests +{ + private readonly Mock _profileRepositoryMock = new(); + private readonly Mock _installationServiceMock = new(); + private readonly Mock _manifestPoolMock = new(); + private readonly Mock _gameSettingsServiceMock = new(); + private readonly Mock _launchRegistryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly GameProfileManager _profileManager; + + /// + /// Initializes a new instance of the class. + /// + public GameProfileManagerHotswapTests() + { + _profileManager = new GameProfileManager( + _profileRepositoryMock.Object, + _installationServiceMock.Object, + _manifestPoolMock.Object, + _gameSettingsServiceMock.Object, + _loggerMock.Object, + _launchRegistryMock.Object); + } + + /// + /// Verifies that updating a non-running profile clears the ActiveWorkspaceId when content changes. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileNotRunning_ClearsActiveWorkspaceIdOnContentChange() + { + // Arrange + const string profileId = "profile-1"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Existing Profile", + ActiveWorkspaceId = "workspace-abc", + EnabledContentIds = ["1.0.0.mod.first"], + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync(new List()); + + var request = new UpdateProfileRequest + { + EnabledContentIds = ["1.0.0.mod.second"], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.True(result.Success); + Assert.Empty(existingProfile.ActiveWorkspaceId); + } + + /// + /// Verifies that updating a running profile with map changes succeeds and preserves ActiveWorkspaceId. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithMapChanges_SucceedsAndPreservesActiveWorkspaceId() + { + // Arrange + const string profileId = "profile-running-1"; + const string oldMapId = "1.0.0.map.oldmap"; + const string newMapId = "1.0.0.mappack.newpack"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [oldMapId], + }; + + var oldMapManifest = new ContentManifest + { + Id = ManifestId.Create(oldMapId), + Name = "Old Map", + ContentType = ContentType.Map, + }; + + var newMapManifest = new ContentManifest + { + Id = ManifestId.Create(newMapId), + Name = "New Map Pack", + ContentType = ContentType.MapPack, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(oldMapId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(oldMapManifest)); + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(newMapId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(newMapManifest)); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [newMapId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.True(result.Success); + Assert.Equal("workspace-live-123", existingProfile.ActiveWorkspaceId); + Assert.Contains(newMapId, existingProfile.EnabledContentIds); + } + + /// + /// Verifies that updating a running profile with locked mod changes fails with a descriptive error. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithModChanges_FailsWithDescriptiveError() + { + // Arrange + const string profileId = "profile-running-2"; + const string baseModId = "1.0.0.mod.base"; + const string addedModId = "1.0.0.mod.shockwave"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [baseModId], + }; + + var modManifest = new ContentManifest + { + Id = ManifestId.Create(addedModId), + Name = "ShockWave Mod", + ContentType = ContentType.Mod, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(addedModId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(modManifest)); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [baseModId, addedModId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("ShockWave Mod", result.FirstError); + Assert.Contains("Mod", result.FirstError); + _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that updating a running profile with game client changes fails with a descriptive error. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithGameClientChanges_FailsWithDescriptiveError() + { + // Arrange + const string profileId = "profile-running-3"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + GameClient = new GameClient { Id = "client-original", Name = "Client 1.04" }, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + var request = new UpdateProfileRequest + { + GameClient = new GameClient { Id = "client-new", Name = "Client 1.06" }, + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("game client", result.FirstError, StringComparison.OrdinalIgnoreCase); + _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that updating immutable metadata on a running profile is rejected. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithImmutableMetadataChanges_Fails() + { + // Arrange + const string profileId = "profile-running-4"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + WorkspaceStrategy = WorkspaceStrategy.SymlinkOnly, + GameInstallationId = "install-1", + CustomExecutablePath = "C:\\game\\generals.exe", + WorkingDirectory = "C:\\game", + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + // 1. Workspace Strategy change + var req1 = new UpdateProfileRequest { WorkspaceStrategy = WorkspaceStrategy.HardLink }; + var res1 = await _profileManager.UpdateProfileAsync(profileId, req1); + Assert.False(res1.Success); + Assert.Contains("workspace strategy", res1.FirstError, StringComparison.OrdinalIgnoreCase); + + // 2. Installation change + var req2 = new UpdateProfileRequest { GameInstallationId = "install-2" }; + var res2 = await _profileManager.UpdateProfileAsync(profileId, req2); + Assert.False(res2.Success); + Assert.Contains("game installation", res2.FirstError, StringComparison.OrdinalIgnoreCase); + + // 3. Custom executable path change + var req3 = new UpdateProfileRequest { CustomExecutablePath = "C:\\game\\new_generals.exe" }; + var res3 = await _profileManager.UpdateProfileAsync(profileId, req3); + Assert.False(res3.Success); + Assert.Contains("custom executable path", res3.FirstError, StringComparison.OrdinalIgnoreCase); + + // 4. Working directory change + var req4 = new UpdateProfileRequest { WorkingDirectory = "C:\\other_dir" }; + var res4 = await _profileManager.UpdateProfileAsync(profileId, req4); + Assert.False(res4.Success); + Assert.Contains("working directory", res4.FirstError, StringComparison.OrdinalIgnoreCase); + } + + private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new() + { + LaunchId = launchId, + ProfileId = profileId, + WorkspaceId = workspaceId, + ProcessInfo = new GameProcessInfo + { + ProcessId = 1234, + ProcessName = "generals.exe", + StartTime = DateTime.UtcNow, + }, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs new file mode 100644 index 000000000..69271663a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.UserData; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; +using GenHub.Core.Models.Launching; +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; +using CoreContentDisplayItem = GenHub.Core.Models.Content.ContentDisplayItem; +using GameType = GenHub.Core.Models.Enums.GameType; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels; + +/// +/// Unit tests for runtime hotswap mode behavior. +/// +public class GameProfileSettingsViewModelHotswapTests +{ + private readonly Mock _gameProfileManagerMock = new(); + private readonly Mock _gameSettingsServiceMock = new(); + private readonly Mock _configProviderMock = new(); + private readonly Mock _contentLoaderMock = new(); + private readonly Mock _manifestPoolMock = new(); + private readonly Mock _profileContentLinkerMock = new(); + private readonly Mock _launchRegistryMock = new(); + private readonly GameProfileSettingsViewModel _viewModel; + + /// + /// Initializes a new instance of the class. + /// + public GameProfileSettingsViewModelHotswapTests() + { + _viewModel = new GameProfileSettingsViewModel( + _gameProfileManagerMock.Object, + _gameSettingsServiceMock.Object, + _configProviderMock.Object, + _contentLoaderMock.Object, + null, + null, + _manifestPoolMock.Object, + null, + null, + null, + null, + NullLogger.Instance, + NullLogger.Instance, + _profileContentLinkerMock.Object, + _launchRegistryMock.Object); + } + + /// + /// Verifies that initializing for a running profile activates Hotswap Mode and locks non-hotswappable content. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InitializeForProfileAsync_WhenProfileIsRunning_SetsIsHotswapModeTrueAndLocksNonHotswappableContent() + { + // Arrange + const string profileId = "profile-live-1"; + const string installId = "1.108.steam.gameinstallation.zh"; + const string mapId = "1.0.0.map.desert"; + const string modId = "1.0.0.mod.shockwave"; + + var profile = new GameProfile + { + Id = profileId, + Name = "Live Game Profile", + EnabledContentIds = [installId, mapId, modId], + GameClient = new GameClient + { + Id = "client-zh", + Name = "Zero Hour", + GameType = GameType.ZeroHour, + }, + }; + + _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + var enabledItems = new ObservableCollection + { + new() + { + Id = installId, + ManifestId = installId, + DisplayName = "Command & Conquer: Zero Hour", + ContentType = ContentType.GameInstallation, + GameType = GameType.ZeroHour, + }, + new() + { + Id = mapId, + ManifestId = mapId, + DisplayName = "Tournament Desert", + ContentType = ContentType.Map, + GameType = GameType.ZeroHour, + }, + new() + { + Id = modId, + ManifestId = modId, + DisplayName = "ShockWave Mod", + ContentType = ContentType.Mod, + GameType = GameType.ZeroHour, + }, + }; + + _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile)) + .ReturnsAsync(enabledItems); + _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync()) + .ReturnsAsync([]); + _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>())) + .ReturnsAsync([]); + + _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + // Act + await _viewModel.InitializeForProfileAsync(profileId); + + // Assert + Assert.True(_viewModel.IsHotswapMode); + Assert.False(_viewModel.CanEditImmutableMetadata); + + var mapItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == mapId); + Assert.NotNull(mapItem); + Assert.False(mapItem.IsLocked); + Assert.True(mapItem.CanToggle); + + var modItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == modId); + Assert.NotNull(modItem); + Assert.True(modItem.IsLocked); + Assert.False(modItem.CanToggle); + } + + /// + /// Verifies that initializing for an idle profile sets Hotswap Mode to false. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InitializeForProfileAsync_WhenProfileIsNotRunning_SetsIsHotswapModeFalse() + { + // Arrange + const string profileId = "profile-idle-1"; + const string installId = "1.108.steam.gameinstallation.zh"; + const string modId = "1.0.0.mod.shockwave"; + + var profile = new GameProfile + { + Id = profileId, + Name = "Idle Profile", + EnabledContentIds = [installId, modId], + GameClient = new GameClient + { + Id = "client-zh", + Name = "Zero Hour", + GameType = GameType.ZeroHour, + }, + }; + + _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync(new List()); + + var enabledItems = new ObservableCollection + { + new() + { + Id = installId, + ManifestId = installId, + DisplayName = "Command & Conquer: Zero Hour", + ContentType = ContentType.GameInstallation, + GameType = GameType.ZeroHour, + }, + new() + { + Id = modId, + ManifestId = modId, + DisplayName = "ShockWave Mod", + ContentType = ContentType.Mod, + GameType = GameType.ZeroHour, + }, + }; + + _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile)) + .ReturnsAsync(enabledItems); + _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync()) + .ReturnsAsync([]); + _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>())) + .ReturnsAsync([]); + + _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + // Act + await _viewModel.InitializeForProfileAsync(profileId); + + // Assert + Assert.False(_viewModel.IsHotswapMode); + Assert.True(_viewModel.CanEditImmutableMetadata); + + var modItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == modId); + Assert.NotNull(modItem); + Assert.False(modItem.IsLocked); + Assert.True(modItem.CanToggle); + } + + /// + /// Verifies that SaveAsync in Hotswap Mode invokes UpdateProfileUserDataAsync on the profile content linker. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SaveAsync_WhenInHotswapMode_CallsUpdateProfileUserDataAsync() + { + // Arrange + const string profileId = "profile-live-2"; + const string installId = "1.108.steam.gameinstallation.zh"; + const string mapId = "1.0.0.map.desert"; + + var profile = new GameProfile + { + Id = profileId, + Name = "Live Profile", + EnabledContentIds = [installId, mapId], + GameClient = new GameClient + { + Id = "client-zh", + Name = "Zero Hour", + GameType = GameType.ZeroHour, + }, + }; + + _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + var enabledItems = new ObservableCollection + { + new() + { + Id = installId, + ManifestId = installId, + DisplayName = "Command & Conquer: Zero Hour", + ContentType = ContentType.GameInstallation, + GameType = GameType.ZeroHour, + }, + new() + { + Id = mapId, + ManifestId = mapId, + DisplayName = "Tournament Desert", + ContentType = ContentType.Map, + GameType = GameType.ZeroHour, + }, + }; + + _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile)) + .ReturnsAsync(enabledItems); + _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync()) + .ReturnsAsync([]); + _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>())) + .ReturnsAsync([]); + _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + var mapManifest = new ContentManifest + { + Id = ManifestId.Create(mapId), + Name = "Tournament Desert", + ContentType = ContentType.Map, + }; + var installManifest = new ContentManifest + { + Id = ManifestId.Create(installId), + Name = "Zero Hour", + ContentType = ContentType.GameInstallation, + }; + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(mapId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(mapManifest)); + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(installManifest)); + + _profileContentLinkerMock.Setup(p => p.UpdateProfileUserDataAsync( + profileId, + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + await _viewModel.InitializeForProfileAsync(profileId); + + // Act + await _viewModel.SaveCommand.ExecuteAsync(null); + + // Assert + _profileContentLinkerMock.Verify( + p => p.UpdateProfileUserDataAsync( + profileId, + It.Is>(m => m.Any(x => x.Id.Value == mapId)), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new() + { + LaunchId = launchId, + ProfileId = profileId, + WorkspaceId = workspaceId, + ProcessInfo = new GameProcessInfo + { + ProcessId = 1234, + ProcessName = "generals.exe", + StartTime = DateTime.UtcNow, + }, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs new file mode 100644 index 000000000..903b3a4dd --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Models.GameProfile; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.Reconciliation; + +/// +/// Unit tests verifying that guards running profiles from workspace cleanup. +/// +public class ContentReconciliationServiceHotswapTests +{ + private readonly Mock _profileManagerMock = new(); + private readonly Mock _workspaceManagerMock = new(); + private readonly Mock _manifestPoolMock = new(); + private readonly Mock _casReferenceTrackerMock = new(); + private readonly Mock _casLifecycleManagerMock = new(); + private readonly Mock _launchRegistryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly ContentReconciliationService _reconciliationService; + + /// + /// Initializes a new instance of the class. + /// + public ContentReconciliationServiceHotswapTests() + { + _reconciliationService = new ContentReconciliationService( + _profileManagerMock.Object, + _workspaceManagerMock.Object, + _manifestPoolMock.Object, + _casReferenceTrackerMock.Object, + _casLifecycleManagerMock.Object, + _loggerMock.Object, + _launchRegistryMock.Object); + } + + /// + /// Verifies that ReconcileBulkManifestReplacementAsync skips workspace cleanup for running profiles. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ReconcileBulkManifestReplacementAsync_WhenProfileRunning_SkipsWorkspaceCleanup() + { + // Arrange + const string runningProfileId = "running-profile-1"; + const string oldManifestId = "1.0.0.mod.oldmod"; + const string newManifestId = "1.0.0.mod.newmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-1", + EnabledContentIds = [oldManifestId], + }; + + var newManifest = new ContentManifest + { + Id = ManifestId.Create(newManifestId), + Name = "Updated Content", + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile])); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + _casReferenceTrackerMock.Setup(c => c.TrackManifestReferencesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + var replacements = new Dictionary + { + { oldManifestId, newManifest }, + }; + + // Act + var result = await _reconciliationService.ReconcileBulkManifestReplacementAsync(replacements); + + // Assert + Assert.True(result.Success); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _profileManagerMock.Verify(p => p.UpdateProfileAsync(runningProfileId, It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that ReconcileManifestRemovalAsync skips workspace cleanup for running profiles. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ReconcileManifestRemovalAsync_WhenProfileRunning_SkipsWorkspaceCleanup() + { + // Arrange + const string runningProfileId = "running-profile-2"; + const string manifestId = "1.0.0.mod.deletedmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-2", + EnabledContentIds = [manifestId], + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile])); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + _casReferenceTrackerMock.Setup(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _manifestPoolMock.Setup(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _reconciliationService.ReconcileManifestRemovalAsync(ManifestId.Create(manifestId)); + + // Assert + Assert.True(result.Success); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _profileManagerMock.Verify(p => p.UpdateProfileAsync(runningProfileId, It.IsAny(), It.IsAny()), Times.Never); + } + + private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new() + { + LaunchId = launchId, + ProfileId = profileId, + WorkspaceId = workspaceId, + ProcessInfo = new GameProcessInfo + { + ProcessId = 1234, + ProcessName = "generals.exe", + StartTime = DateTime.UtcNow, + }, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs new file mode 100644 index 000000000..83847e30a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs @@ -0,0 +1,68 @@ +using System; +using GenHub.Core.Models.Workspace; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Workspace; + +/// +/// Contains unit tests for . +/// +public class ContentHotswapClassificationTests +{ + /// + /// Verifies that IsHotswappable returns expected truth values for all content types. + /// + /// The content type under test. + /// The expected boolean result. + [Theory] + [InlineData(ContentType.Map, true)] + [InlineData(ContentType.MapPack, true)] + [InlineData(ContentType.Mod, false)] + [InlineData(ContentType.Patch, false)] + [InlineData(ContentType.GameClient, false)] + [InlineData(ContentType.GameInstallation, false)] + [InlineData(ContentType.Addon, false)] + [InlineData(ContentType.Executable, false)] + [InlineData(ContentType.ModdingTool, false)] + [InlineData(ContentType.Mission, false)] + [InlineData(ContentType.Skin, false)] + [InlineData(ContentType.LanguagePack, false)] + [InlineData(ContentType.UnknownContentType, false)] + public void IsHotswappable_ReturnsExpectedResult(ContentType contentType, bool expected) + { + // Act + var result = ContentHotswapClassification.IsHotswappable(contentType); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Verifies that IsLocked returns the exact opposite of IsHotswappable. + /// + /// The content type under test. + /// The expected boolean result. + [Theory] + [InlineData(ContentType.Map, false)] + [InlineData(ContentType.MapPack, false)] + [InlineData(ContentType.Mod, true)] + [InlineData(ContentType.Patch, true)] + [InlineData(ContentType.GameClient, true)] + [InlineData(ContentType.GameInstallation, true)] + [InlineData(ContentType.Addon, true)] + [InlineData(ContentType.Executable, true)] + [InlineData(ContentType.ModdingTool, true)] + [InlineData(ContentType.Mission, true)] + [InlineData(ContentType.Skin, true)] + [InlineData(ContentType.LanguagePack, true)] + [InlineData(ContentType.UnknownContentType, true)] + public void IsLocked_ReturnsOppositeOfIsHotswappable(ContentType contentType, bool expected) + { + // Act + var result = ContentHotswapClassification.IsLocked(contentType); + + // Assert + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs index 6ac917d3b..b9b689621 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs @@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.Messaging; using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Storage; using GenHub.Core.Interfaces.Workspace; @@ -29,7 +30,8 @@ public class ContentReconciliationService( IContentManifestPool manifestPool, ICasReferenceTracker referenceTracker, ICasLifecycleManager casLifecycleManager, - ILogger logger) : IContentReconciliationService, IDisposable + ILogger logger, + ILaunchRegistry? launchRegistry = null) : IContentReconciliationService, IDisposable { private readonly SemaphoreSlim _reconciliationLock = new(1, 1); @@ -395,6 +397,16 @@ private async Task InvalidateWorkspacesForManifestInternal int invalidatedCount = 0; foreach (var profile in affectedProfiles) { + if (launchRegistry != null) + { + var activeLaunches = await launchRegistry.GetAllActiveLaunchesAsync(); + if (activeLaunches.Any(l => string.Equals(l.ProfileId, profile.Id, StringComparison.OrdinalIgnoreCase))) + { + logger.LogWarning("Skipping workspace invalidation for running profile '{ProfileName}'", profile.Name); + continue; + } + } + logger.LogDebug("Invalidating workspace for profile '{ProfileName}' due to manifest update", profile.Name); var cleanupResult = await workspaceManager.CleanupWorkspaceAsync(profile.ActiveWorkspaceId!, cancellationToken); if (!cleanupResult.Success) @@ -492,6 +504,16 @@ private async Task> ReconcileBulkManifestR bool workspaceInvalidated = false; + if (launchRegistry != null) + { + var activeLaunches = await launchRegistry.GetAllActiveLaunchesAsync(); + if (activeLaunches.Any(l => string.Equals(l.ProfileId, profile.Id, StringComparison.OrdinalIgnoreCase))) + { + logger.LogWarning("Skipping workspace cleanup for running profile '{ProfileName}'", profile.Name); + continue; + } + } + // Clear workspace to force launch-time sync if (!string.IsNullOrEmpty(profile.ActiveWorkspaceId)) { @@ -576,6 +598,17 @@ private async Task> ReconcileManifestRemov .ToList(); bool workspaceInvalidated = false; + + if (launchRegistry != null) + { + var activeLaunches = await launchRegistry.GetAllActiveLaunchesAsync(); + if (activeLaunches.Any(l => string.Equals(l.ProfileId, profile.Id, StringComparison.OrdinalIgnoreCase))) + { + logger.LogWarning("Skipping workspace cleanup for running profile '{ProfileName}'", profile.Name); + continue; + } + } + if (!string.IsNullOrEmpty(profile.ActiveWorkspaceId)) { logger.LogDebug("Cleaning up workspace '{WorkspaceId}' for deleted content in profile '{ProfileName}'", profile.ActiveWorkspaceId, profile.Name); diff --git a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs index 329907b24..b376ece95 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs @@ -8,11 +8,13 @@ using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Models.GameClients; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Models.Workspace; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.Services; @@ -25,7 +27,8 @@ public class GameProfileManager( IGameInstallationService installationService, IContentManifestPool manifestPool, IGameSettingsService gameSettingsService, - ILogger logger) : IGameProfileManager + ILogger logger, + ILaunchRegistry? launchRegistry = null) : IGameProfileManager { /// public async Task> CreateProfileAsync(CreateProfileRequest request, CancellationToken cancellationToken = default) @@ -190,6 +193,67 @@ public async Task> UpdateProfileAsync(string var previousEnabledContentIds = profile.EnabledContentIds?.ToList() ?? []; var previousGameClientId = profile.GameClient?.Id; + // Check if profile is currently running + var isRunning = false; + if (launchRegistry != null) + { + var activeLaunches = await launchRegistry.GetAllActiveLaunchesAsync(); + isRunning = activeLaunches.Any(l => string.Equals(l.ProfileId, profileId, StringComparison.OrdinalIgnoreCase)); + } + + if (isRunning) + { + // Validate immutable metadata + if (request.WorkspaceStrategy.HasValue && request.WorkspaceStrategy.Value != profile.WorkspaceStrategy) + { + return ProfileOperationResult.CreateFailure("Cannot change workspace strategy while profile is running."); + } + + if (!string.IsNullOrEmpty(request.GameInstallationId) && !string.Equals(request.GameInstallationId, profile.GameInstallationId, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change game installation while profile is running."); + } + + if (request.CustomExecutablePath != null && !string.Equals(request.CustomExecutablePath, profile.CustomExecutablePath, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change custom executable path while profile is running."); + } + + if (request.WorkingDirectory != null && !string.Equals(request.WorkingDirectory, profile.WorkingDirectory, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change working directory while profile is running."); + } + + if (request.GameClient != null && !string.Equals(request.GameClient.Id, profile.GameClient?.Id, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change game client while profile is running."); + } + + // Validate changed content is hotswappable + if (request.EnabledContentIds != null) + { + var newContentIds = request.EnabledContentIds.ToList(); + var addedIds = newContentIds.Except(previousEnabledContentIds, StringComparer.OrdinalIgnoreCase).ToList(); + var removedIds = previousEnabledContentIds.Except(newContentIds, StringComparer.OrdinalIgnoreCase).ToList(); + var changedIds = addedIds.Concat(removedIds).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + foreach (var id in changedIds) + { + var manifestResult = await manifestPool.GetManifestAsync(ManifestId.Create(id), cancellationToken); + if (!manifestResult.Success || manifestResult.Data == null) + { + return ProfileOperationResult.CreateFailure($"Cannot modify content '{id}' while profile is running: manifest not found."); + } + + var manifest = manifestResult.Data; + if (!ContentHotswapClassification.IsHotswappable(manifest.ContentType)) + { + return ProfileOperationResult.CreateFailure($"Cannot modify content '{manifest.Name}' of type '{manifest.ContentType}' while profile is running. Only maps and map packs can be hot swapped during an active game session."); + } + } + } + } + if (request.Name != null) { if (!TryValidateProfileName(request.Name, out var nameValidationError)) @@ -200,7 +264,7 @@ public async Task> UpdateProfileAsync(string profile.Name = request.Name; } - CheckAndHandleContentChanges(profile, request, previousEnabledContentIds, previousGameClientId); + CheckAndHandleContentChanges(profile, request, previousEnabledContentIds, previousGameClientId, isRunning); ApplyUpdateRequestToProfile(profile, request); GameSettingsMapper.UpdateFromRequest(profile, request); @@ -399,7 +463,8 @@ private void CheckAndHandleContentChanges( GameProfile profile, UpdateProfileRequest request, List previousEnabledContentIds, - string? previousGameClientId) + string? previousGameClientId, + bool isRunning) { bool contentChanged = false; if (request.EnabledContentIds != null) @@ -414,7 +479,7 @@ private void CheckAndHandleContentChanges( contentChanged = contentChanged || !string.Equals(previousGameClientId, newGameClientId, StringComparison.OrdinalIgnoreCase); } - if (contentChanged && !string.IsNullOrEmpty(profile.ActiveWorkspaceId)) + if (contentChanged && !isRunning && !string.IsNullOrEmpty(profile.ActiveWorkspaceId)) { logger.LogDebug( "Profile '{ProfileName}' content changed - clearing ActiveWorkspaceId '{WorkspaceId}' to force workspace rebuild on next launch", diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs index 96cb9f194..c089dbc07 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs @@ -525,9 +525,9 @@ public GameProfileItemViewModel(string profileId, IGameProfile profile, string i public bool IsWorkspacePrepared => !string.IsNullOrEmpty(ActiveWorkspaceId); /// - /// Gets a value indicating whether the profile can be edited (not running and not being prepared). + /// Gets a value indicating whether the profile can be edited (not being prepared). /// - public bool CanEdit => !IsProcessRunning && !IsPreparingWorkspace; + public bool CanEdit => !IsPreparingWorkspace; /// /// Gets a value indicating whether the profile can be launched (not running). diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index 493d49e7a..d53b5ed06 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -463,6 +463,37 @@ private async Task SaveAsync() await GameSettingsViewModel.SaveSettingsCommand.ExecuteAsync(null); } + if (IsHotswapMode && _profileContentLinker != null && _manifestPool != null) + { + var manifests = new List(); + foreach (var id in enabledContentIds) + { + var manifestRes = await _manifestPool.GetManifestAsync(ManifestId.Create(id)); + if (manifestRes.Success && manifestRes.Data != null) + { + manifests.Add(manifestRes.Data); + } + } + + var liveUpdateResult = await _profileContentLinker.UpdateProfileUserDataAsync( + _currentProfileId, + manifests, + GameTypeFilter); + + if (liveUpdateResult.Success) + { + _localNotificationService.ShowSuccess( + "Live Update Complete", + "Content changes have been applied to the active game session."); + } + else + { + _localNotificationService.ShowWarning( + "Live Update Warning", + $"Profile saved, but live user data sync reported: {liveUpdateResult.FirstError}"); + } + } + StatusMessage = "Profile updated successfully"; _logger?.LogInformation("Updated profile {ProfileId} with {ContentCount} enabled content items", CurrentProfileId, enabledContentIds.Count); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index 4cda6cfe5..8b141cffe 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -36,6 +36,7 @@ public virtual async Task InitializeForNewProfileAsync() } CurrentProfileId = null; + IsHotswapMode = false; Name = ProfileConstants.DefaultProfileName; Description = "A new game profile"; ColorValue = "#1976D2"; @@ -178,9 +179,20 @@ public virtual async Task InitializeForProfileAsync(string profileId) } } + if (_launchRegistry != null) + { + var activeLaunches = await _launchRegistry.GetAllActiveLaunchesAsync(); + IsHotswapMode = activeLaunches.Any(l => string.Equals(l.ProfileId, profileId, StringComparison.OrdinalIgnoreCase)); + } + else + { + IsHotswapMode = false; + } + await LoadEnabledContentForProfileAsync(profile); await LoadAvailableGameInstallationsAsync(); await LoadAvailableContentAsync(); + UpdateAllItemsHotswapState(); await RefreshVisibleFiltersAsync(); var enabledInstallation = EnabledContent.FirstOrDefault(c => c.ContentType == Core.Models.Enums.ContentType.GameInstallation); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs index aba8bbfe4..d19d045cb 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs @@ -197,4 +197,13 @@ public ContentType SelectedContentType [ObservableProperty] private GameType _selectedLocalGameType = Core.Models.Enums.GameType.ZeroHour; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanEditImmutableMetadata))] + private bool _isHotswapMode; + + /// + /// Gets a value indicating whether immutable profile metadata can be edited (i.e. not in hotswap mode). + /// + public bool CanEditImmutableMetadata => !IsHotswapMode; } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs index e7aaec40f..401b69868 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs @@ -13,11 +13,14 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.UserData; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Workspace; using GenHub.Features.GameProfiles.Services; using GenHub.Features.Notifications.Services; using GenHub.Features.Notifications.ViewModels; @@ -121,8 +124,11 @@ private static void PopulateGameSettings(UpdateProfileRequest request, UpdatePro if (gameSettings != null) GameSettingsMapper.PopulateRequest(request, gameSettings); } - private static ContentDisplayItem ConvertToViewModelContentDisplayItem(Core.Models.Content.ContentDisplayItem coreItem) + private ContentDisplayItem ConvertToViewModelContentDisplayItem(Core.Models.Content.ContentDisplayItem coreItem) { + var isLocked = IsHotswapMode && ContentHotswapClassification.IsLocked(coreItem.ContentType); + var canToggle = !IsHotswapMode || ContentHotswapClassification.IsHotswappable(coreItem.ContentType); + return new ContentDisplayItem { ManifestId = ManifestId.Create(coreItem.ManifestId), @@ -137,8 +143,8 @@ private static ContentDisplayItem ConvertToViewModelContentDisplayItem(Core.Mode IsEnabled = coreItem.IsEnabled, IsEditable = coreItem.IsEditable, SourcePath = coreItem.SourcePath, - IsLocked = false, - CanToggle = true, + IsLocked = isLocked, + CanToggle = canToggle, }; } @@ -198,6 +204,27 @@ private static void ValidateSingleDependencyWarning( private static bool HasCompatibleCatalogMatch(string declaredId, string availableId) => DependencyResolver.HasCompatibleCatalogIdentity(declaredId, availableId); + private void UpdateAllItemsHotswapState() + { + foreach (var item in EnabledContent) + { + item.IsLocked = IsHotswapMode && ContentHotswapClassification.IsLocked(item.ContentType); + item.CanToggle = !IsHotswapMode || ContentHotswapClassification.IsHotswappable(item.ContentType); + } + + foreach (var item in AvailableContent) + { + item.IsLocked = IsHotswapMode && ContentHotswapClassification.IsLocked(item.ContentType); + item.CanToggle = !IsHotswapMode || ContentHotswapClassification.IsHotswappable(item.ContentType); + } + + foreach (var item in AvailableGameInstallations) + { + item.IsLocked = IsHotswapMode; + item.CanToggle = !IsHotswapMode; + } + } + private readonly IGameProfileManager? _gameProfileManager; private readonly IGameSettingsService? _gameSettingsService; private readonly IConfigurationProviderService? _configurationProvider; @@ -211,6 +238,8 @@ private static bool HasCompatibleCatalogMatch(string declaredId, string availabl private readonly IDialogService? _dialogService; private readonly ILogger? _logger; private readonly ILogger? _gameSettingsLogger; + private readonly IProfileContentLinker? _profileContentLinker; + private readonly ILaunchRegistry? _launchRegistry; private readonly NotificationService _localNotificationService = new(NullLogger.Instance); @@ -249,6 +278,8 @@ private static bool HasCompatibleCatalogMatch(string declaredId, string availabl /// The dialog service. /// The logger for this view model. /// The logger for the game settings view model. + /// The profile content linker service. + /// The launch registry service. public GameProfileSettingsViewModel( IGameProfileManager? gameProfileManager, IGameSettingsService? gameSettingsService, @@ -262,7 +293,9 @@ public GameProfileSettingsViewModel( IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, ILogger? logger, - ILogger? gameSettingsLogger) + ILogger? gameSettingsLogger, + IProfileContentLinker? profileContentLinker = null, + ILaunchRegistry? launchRegistry = null) { _gameProfileManager = gameProfileManager; _gameSettingsService = gameSettingsService; @@ -277,6 +310,8 @@ public GameProfileSettingsViewModel( _dialogService = dialogService; _logger = logger; _gameSettingsLogger = gameSettingsLogger; + _profileContentLinker = profileContentLinker; + _launchRegistry = launchRegistry; NotificationManager = new NotificationManagerViewModel( _localNotificationService, diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml index 9f6a53c88..74c3b8ff9 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileGeneralSettingsView.axaml @@ -201,7 +201,7 @@ - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml index 0428518c8..2d8d349f8 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml @@ -619,6 +619,7 @@ - - - - - - - - - - - - - - @@ -161,15 +58,15 @@ - + @@ -183,18 +80,21 @@ VerticalAlignment="Center" /> - + @@ -211,7 +111,7 @@ - + @@ -238,7 +138,7 @@ @@ -248,10 +148,8 @@ - @@ -259,79 +157,76 @@ + Foreground="#AAAAAA" /> - + - - - - - - - - - - - - - @@ -422,7 +319,7 @@ + Padding="8,4"> @@ -441,7 +338,7 @@ - + @@ -451,6 +348,7 @@ @@ -520,14 +419,12 @@ - diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 12ae3350a..d7a02cafc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -26,28 +26,28 @@ @@ -83,7 +83,7 @@ @@ -179,9 +179,9 @@ - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs index 923853957..a9d1b5ac9 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs @@ -31,13 +31,6 @@ protected override void OnOpened(EventArgs e) GenHub.Infrastructure.Interop.AdminDragDropFix.Apply(this, OnAdminDrop); } - /// - protected override void OnClosed(EventArgs e) - { - base.OnClosed(e); - (DataContext as IDisposable)?.Dispose(); - } - /// protected override void OnDataContextChanged(EventArgs e) { @@ -54,13 +47,12 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Content Folder", AllowMultiple = false, }); - - return folders.Count > 0 ? folders[0].Path.LocalPath : null; + return result.Count > 0 ? result[0].Path.LocalPath : null; }; vm.BrowseFileAction = async () => @@ -70,48 +62,17 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { - Title = "Select Archive File", + Title = "Select Files", AllowMultiple = true, - FileTypeFilter = - [ - new FilePickerFileType("Archive Files") - { - Patterns = ["*.zip", "*.7z", "*.rar", "*.tar", "*.gz", "*.big"], - }, - new FilePickerFileType("All Files") - { - Patterns = ["*.*"], - }, - ], + FileTypeFilter = [FilePickerFileTypes.All, new("Zip Archives") { Patterns = ["*.zip"] }], }); - - return files.Count > 0 ? files.Select(f => f.Path.LocalPath).ToList() : null; + return result.Count > 0 ? result.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); @@ -164,4 +125,12 @@ private async void OnDrop(object? sender, DragEventArgs e) } } } + + private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + BeginMoveDrag(e); + } + } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml index c64a7e1c9..0ed690386 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml @@ -31,22 +31,23 @@ @@ -80,41 +81,10 @@ - - - - - @@ -170,10 +140,10 @@ + Padding="12,24"> @@ -840,7 +865,10 @@ IsVisible="{Binding LoadingError}" ZIndex="1000"> - + + + + @@ -488,8 +529,7 @@ - + @@ -511,7 +551,7 @@ - + @@ -580,13 +620,18 @@ CornerRadius="8" FontWeight="Medium" Margin="0,0,12,0" /> - @@ -619,7 +664,10 @@ IsVisible="{Binding LoadingError}" ZIndex="1000"> - + + + + @@ -131,7 +131,7 @@ Spacing="8" Margin="0,0,0,8" IsVisible="{Binding IsValidating}"> - + diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index 543bdf0ea..c04ca9c38 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -28,9 +28,9 @@ @@ -92,10 +92,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + @@ -211,7 +255,7 @@ - @@ -235,7 +279,7 @@ MinHeight="220"> + Foreground="#30A855F7" Width="32" Height="32" /> From f6a72c9d6e1f6dddbe3246b76115fdadc6c264ca Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:38:33 +0000 Subject: [PATCH 39/44] fix(deliverer): use OpenArchive for SharpCompress 0.48.0 compatibility --- .../Utilities/BoundedArchiveExtractorTests.cs | 3 +-- .../Services/CommunityOutpost/CommunityOutpostDeliverer.cs | 3 +-- .../Features/Content/Services/GitHub/GitHubContentDeliverer.cs | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs index c6b54a97f..23b7b8033 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Utilities/BoundedArchiveExtractorTests.cs @@ -318,8 +318,7 @@ public async Task CopyEntryToFileAsync_RejectsArchiveThatUnderstatesItsDeclaredS var archivePath = Path.Combine(_workingDirectory, "spoofed.zip"); ArchiveFixtures.CreateWithSpoofedEntrySize(archivePath, "bomb.dat", actualBytes, declaredBytes); - using var stream = File.OpenRead(archivePath); - using var archive = ArchiveFactory.Open(stream); + using var archive = ArchiveFactory.OpenArchive(archivePath); var entry = archive.Entries.First(e => !e.IsDirectory); Assert.Equal(declaredBytes, entry.Size); diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index b37d6acd9..8f71bb5ac 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -98,8 +98,7 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var stream = File.OpenRead(archivePath); - using var archive = ArchiveFactory.Open(stream); + using var archive = ArchiveFactory.OpenArchive(fileInfo); var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); if (fileEntries.Count > CommunityOutpostConstants.MaxArchiveEntries) diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index add7c38de..eed7c2a24 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -384,8 +384,7 @@ private async Task ExtractArchiveAsync( await Task.Run( async () => { - using var stream = File.OpenRead(archiveFile); - using var archive = ArchiveFactory.Open(stream); + using var archive = ArchiveFactory.OpenArchive(new FileInfo(archiveFile)); var fileEntries = archive.Entries.Where(e => !e.IsDirectory).ToList(); if (fileEntries.Count > GitHubConstants.MaxArchiveEntries) From da31e5010196afce14483d973145586ab7358dd8 Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:49:45 +0000 Subject: [PATCH 40/44] fix(ui): address Kilo and CodeRabbit review findings and restore semantic theme tokens --- .../GameProfileManagerHotswapTests.cs | 3 +- GenHub/GenHub/App.axaml | 1 + .../Downloads/Views/PublisherCardView.axaml | 59 +++++++----- .../GameProfileSettingsViewModel.Commands.cs | 15 ++- .../Views/AddLocalContentView.axaml | 22 ++--- .../Views/AddLocalContentWindow.axaml.cs | 17 +++- .../Views/GameProfileContentEditorView.axaml | 17 ++-- .../GameProfileContentSettingsView.axaml | 2 +- .../GameProfileSettingsContentView.axaml | 50 ++++------ .../Views/GameProfileSettingsWindow.axaml | 91 ++++--------------- .../GitHub/Views/GitHubTokenDialogView.axaml | 4 +- .../Info/Views/GenHubInfoSectionView.axaml | 20 ++-- .../Views/NotificationFeedView.axaml | 80 ++-------------- 13 files changed, 143 insertions(+), 238 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs index 809317d71..42e313b51 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs @@ -194,7 +194,8 @@ public async Task UpdateProfileAsync_WhenProfileRunning_WithModChanges_FailsWith // Assert Assert.False(result.Success); Assert.Contains("ShockWave Mod", result.FirstError); - Assert.Contains("Mod", result.FirstError); + Assert.Contains("while profile is running", result.FirstError, StringComparison.OrdinalIgnoreCase); + Assert.Contains("hot swapped", result.FirstError, StringComparison.OrdinalIgnoreCase); _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); } diff --git a/GenHub/GenHub/App.axaml b/GenHub/GenHub/App.axaml index 73aa65a2a..64c0d9359 100644 --- a/GenHub/GenHub/App.axaml +++ b/GenHub/GenHub/App.axaml @@ -26,6 +26,7 @@ + + + + + @@ -83,7 +83,7 @@ @@ -179,9 +179,9 @@ - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs index a9d1b5ac9..ae7c25760 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs @@ -66,7 +66,16 @@ protected override void OnDataContextChanged(EventArgs e) { Title = "Select Files", AllowMultiple = true, - FileTypeFilter = [FilePickerFileTypes.All, new("Zip Archives") { Patterns = ["*.zip"] }], + FileTypeFilter = + [ + new("Supported Content Files (*.zip, *.7z, *.rar, *.tar, *.gz, *.big)") + { + Patterns = ["*.zip", "*.7z", "*.rar", "*.tar", "*.gz", "*.big"], + }, + new("Zip Archives (*.zip)") { Patterns = ["*.zip"] }, + new("BIG Files (*.big)") { Patterns = ["*.big"] }, + FilePickerFileTypes.All, + ], }); return result.Count > 0 ? result.Select(f => f.Path.LocalPath).ToList() : null; }; @@ -130,6 +139,12 @@ 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; + return; + } + BeginMoveDrag(e); } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml index 0ed690386..9fced6788 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml @@ -31,22 +31,21 @@ @@ -83,8 +82,8 @@ @@ -140,8 +139,8 @@ diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml index b27ab237a..46ddd4c5a 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml @@ -181,7 +181,7 @@ - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml index 8861cd157..9cc2ae95e 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml @@ -87,7 +87,7 @@ - + @@ -95,8 +95,8 @@ @@ -219,6 +219,13 @@ + + + + + + + - - - - - - - - @@ -605,8 +589,8 @@ Tag="{Binding Path, Converter={x:Static ObjectConverters.Equal}, ConverterParameter={Binding $parent[ItemsControl].((vm:GameProfileSettingsViewModel)DataContext).CoverPath}}"> diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml index 1f37befb4..b822070b2 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsWindow.axaml @@ -84,7 +84,7 @@ - + @@ -92,8 +92,8 @@ @@ -204,75 +204,24 @@ - - - - - - - - - - - - - - - - - - @@ -326,12 +275,12 @@ @@ -340,7 +289,7 @@ - + @@ -348,7 +297,7 @@ @@ -472,7 +421,7 @@ diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index c04ca9c38..c6b9a15af 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -28,9 +28,9 @@ @@ -92,10 +92,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + Foreground="{DynamicResource AccentBadgeBackgroundBrush}" Width="32" Height="32" /> From 59e427e995bcca19c52743a2a8b870940371aa54 Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:11:35 +0000 Subject: [PATCH 41/44] fix(ui): backport PR #400 translucent glass styling for Profile Settings and Local Import views --- .../Views/AddLocalContentView.axaml | 22 ++++---- .../Views/GameProfileContentEditorView.axaml | 17 +++--- .../GameProfileContentSettingsView.axaml | 2 +- .../GameProfileSettingsContentView.axaml | 52 +++++++++---------- .../Views/GameProfileSettingsWindow.axaml | 49 +++++++++-------- 5 files changed, 75 insertions(+), 67 deletions(-) diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 93e1011ff..d7a02cafc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -26,28 +26,28 @@ @@ -83,7 +83,7 @@ @@ -179,9 +179,9 @@ - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml index 9fced6788..0ed690386 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml @@ -31,21 +31,22 @@ @@ -82,8 +83,8 @@ @@ -139,8 +140,8 @@ diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml index 46ddd4c5a..b27ab237a 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentSettingsView.axaml @@ -181,7 +181,7 @@ - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml index 9cc2ae95e..11c8d315e 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileSettingsContentView.axaml @@ -229,36 +229,30 @@ - - - - - @@ -529,13 +523,16 @@ Margin="0,0,8,8" Padding="4" CornerRadius="8" - Background="#15FFFFFF" - BorderBrush="#30FFFFFF" - BorderThickness="1" + Classes="selectable-tile" ToolTip.Tip="{Binding DisplayName}" Tag="{Binding Path, Converter={x:Static ObjectConverters.Equal}, ConverterParameter={Binding $parent[ItemsControl].((vm:GameProfileSettingsViewModel)DataContext).IconPath}}"> - + + - - - - - + + @@ -286,6 +301,7 @@ - From 9e73a19af5ae908174d094e8abbe67fbf94b97af Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:26:31 +0000 Subject: [PATCH 42/44] fix(reconciliation,userdata): fail bulk removal on blocked manifests and filter adoption by game Return failure when bulk removal has manifests blocked by running or unreconciled profiles to prevent caller content storage deletion. Filter manifests by target game during skip-cleanup adoption to prevent cross-game user data linkage. --- ...ontentReconciliationServiceHotswapTests.cs | 10 +- .../ProfileContentLinkerServiceTests.cs | 97 +++++++++++++++++++ .../Services/ContentReconciliationService.cs | 8 +- .../Services/ProfileContentLinkerService.cs | 8 +- 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs index 09d2e163c..de0f80efe 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs @@ -141,7 +141,8 @@ public async Task OrchestrateBulkRemovalAsync_WhenProfileRunning_ProtectsManifes var result = await _reconciliationService.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); // Assert - Assert.True(result.Success); + Assert.False(result.Success); + Assert.Contains("active or unreconciled profiles", result.FirstError, StringComparison.OrdinalIgnoreCase); _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); _manifestPoolMock.Verify(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _casReferenceTrackerMock.Verify(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -231,11 +232,8 @@ public async Task OrchestrateBulkRemovalAsync_WithMixedRunningAndIdleProfiles_Up var result = await _reconciliationService.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); // Assert - Assert.True(result.Success); - Assert.NotNull(result.Data); - Assert.Equal(1, result.Data.ProfilesUpdated); - Assert.Equal(1, result.Data.WorkspacesInvalidated); - Assert.Equal(1, result.Data.FailedProfilesCount); + Assert.False(result.Success); + Assert.Contains("active or unreconciled profiles", result.FirstError, StringComparison.OrdinalIgnoreCase); // Idle profile's workspace cleaned up and profile updated _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync("workspace-idle", It.IsAny()), Times.Once); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs index c2b02dc9d..21c7dbc1a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs @@ -152,6 +152,103 @@ public async Task SwitchProfileUserDataAsync_WhenAdoptionInstallFails_ReturnsFai _userDataTrackerMock.Verify(t => t.ActivateProfileUserDataAsync(It.IsAny(), It.IsAny()), Times.Never); } + /// + /// Verifies that SwitchProfileUserDataAsync adopts only manifests matching targetGame or GameType.Unknown during skipCleanup. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SwitchProfileUserDataAsync_WhenSkipCleanupTrue_AdoptsOnlyManifestsMatchingTargetGameOrUnknownAsync() + { + // Arrange + const string oldProfileId = "profile-old"; + const string newProfileId = "profile-new"; + const GameType gameType = GameType.ZeroHour; + + var matchingManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.zh-map", + ProfileId = oldProfileId, + TargetGame = GameType.ZeroHour, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\ZH.map", RelativePath = "Maps\\ZH.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + var otherGameManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.gen-map", + ProfileId = oldProfileId, + TargetGame = GameType.Generals, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\Gen.map", RelativePath = "Maps\\Gen.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + var unknownGameManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.unknown-map", + ProfileId = oldProfileId, + TargetGame = GameType.Unknown, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\Unknown.map", RelativePath = "Maps\\Unknown.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(oldProfileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([matchingManifest, otherGameManifest, unknownGameManifest])); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + It.IsAny(), + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string manifestId, string profileId, GameType game, IEnumerable _, string _, string _, CancellationToken _) => + OperationResult.CreateSuccess(new UserDataManifest { ManifestId = manifestId, ProfileId = profileId, TargetGame = game })); + + _userDataTrackerMock.Setup(t => t.GetGameUserDataAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + // Act + var result = await _linkerService.SwitchProfileUserDataAsync( + oldProfileId: oldProfileId, + newProfileId: newProfileId, + newManifests: [], + targetGame: gameType, + skipCleanup: true); + + // Assert + Assert.True(result.Success); + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.zh-map", + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.unknown-map", + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.gen-map", + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + /// /// Verifies that PrepareProfileUserDataAsync cleans up any lingering active user data from other profiles before activating the target profile. /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs index acadb121c..ab2c1afa1 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentReconciliationService.cs @@ -297,8 +297,12 @@ public async Task> OrchestrateBulkRemovalA } } - // Return success even with partial failures to allow cleanup of old manifests. - // Failed manifests are logged for visibility. + if (failedManifests.Count > 0) + { + return OperationResult.CreateFailure( + $"Failed to remove manifests still referenced by active or unreconciled profiles: {string.Join(", ", failedManifests)}"); + } + return OperationResult.CreateSuccess(totalResult); } catch (OperationCanceledException) diff --git a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs index f183508d7..fe620cb45 100644 --- a/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs +++ b/GenHub/GenHub/Features/UserData/Services/ProfileContentLinkerService.cs @@ -73,7 +73,11 @@ public async Task> SwitchProfileUserDataAsync( var oldUserDataResult = await userDataTracker.GetProfileUserDataAsync(oldProfileId, cancellationToken); if (oldUserDataResult.Success && oldUserDataResult.Data != null) { - var fileCount = oldUserDataResult.Data.Sum(m => m.InstalledFiles.Count); + var matchingManifests = oldUserDataResult.Data + .Where(m => m.TargetGame == targetGame || m.TargetGame == GameType.Unknown) + .ToList(); + + var fileCount = matchingManifests.Sum(m => m.InstalledFiles.Count); if (fileCount > 100) { logger.LogInformation("[ProfileContentLinker] Linking large number of maps ({Count}). This might take a while.", fileCount); @@ -81,7 +85,7 @@ public async Task> SwitchProfileUserDataAsync( // Register this manifest's files for the new profile as well // This ensures they are tracked and won't be deleted when switching FROM the new profile later - foreach (var manifest in oldUserDataResult.Data) + foreach (var manifest in matchingManifests) { cancellationToken.ThrowIfCancellationRequested(); From 1666a52607e9a65f1e91c4729518850923c78d5b Mon Sep 17 00:00:00 2001 From: Undead <110314402+undead2146@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:36:45 +0000 Subject: [PATCH 43/44] fix(github,ui,wizard): resolve setup wizard cursor, info changelog rate limiting, and github api token sync Fix SetupWizardView cursor property from invalid Default to Arrow. Add automatic PAT token synchronization and fallback error handling in OctokitGitHubApiClient, SettingsViewModel, and ChangelogsViewModel. Restrict PR artifact polling to subscribed PRs and reuse discovered release metadata in GitHubResolver. --- .../GenHub.Core/Constants/GitHubConstants.cs | 6 + .../Interfaces/Github/IGitHubApiClient.cs | 10 + .../Services/OctokitGitHubApiClientTests.cs | 114 +++++++++ .../Services/VelopackUpdateManager.cs | 80 +++--- .../GitHubTopicsDiscoverer.cs | 33 +-- .../Content/Services/GitHub/GitHubResolver.cs | 42 ++-- .../Views/Wizard/SetupWizardView.axaml | 2 +- .../GitHub/Services/OctokitGitHubApiClient.cs | 235 ++++++++++++++++-- .../ViewModels/GitHubTokenDialogViewModel.cs | 7 +- .../Info/ViewModels/ChangelogsViewModel.cs | 4 + .../Settings/ViewModels/SettingsViewModel.cs | 9 +- .../ContentPipelineModule.cs | 9 +- .../SharedViewModelModule.cs | 3 +- 13 files changed, 465 insertions(+), 89 deletions(-) diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs index 0a567a174..a5625bbc3 100644 --- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs +++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs @@ -14,6 +14,12 @@ public static class GitHubConstants /// Default rate limit reset period in hours. public const int DefaultRateLimitResetHours = 1; + /// Environment variable name for standard GitHub token. + public const string GitHubTokenEnvVar = "GITHUB_TOKEN"; + + /// Environment variable name for GenHub-specific GitHub token. + public const string GenHubTokenEnvVar = "GENHUB_GITHUB_TOKEN"; + // Build parsing constants /// String identifier for Zero Hour game variant. diff --git a/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs index 4124aa0d2..ac0a7f8bc 100644 --- a/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs +++ b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs @@ -13,6 +13,11 @@ public interface IGitHubApiClient /// bool IsAuthenticated { get; } + /// + /// Gets a value indicating whether the GitHub API rate limit is reached. + /// + bool IsRateLimited { get; } + /// /// Gets the latest release from the specified repository. /// @@ -121,6 +126,11 @@ Task DownloadArtifactAsync( /// The GitHub token. void SetAuthenticationToken(SecureString token); + /// + /// Clears any configured authentication token. + /// + void ClearAuthenticationToken(); + /// /// Gets the currently authenticated user. /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs index 0e9158aff..7f6f802e7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs @@ -132,4 +132,118 @@ public void SetAuthenticationToken_ThrowsWithMockClient() // Act & Assert Assert.Throws(() => api.SetAuthenticationToken(secureToken)); } + + /// + /// Verifies that credentials are automatically loaded from IGitHubTokenStorage. + /// + [Fact] + public void EnsureCredentialsLoaded_LoadsFromTokenStorage() + { + // Arrange + var concreteClient = new GitHubClient(new ProductHeaderValue("test")); + var secureToken = new SecureString(); + foreach (char c in "stored-secret-pat") + { + secureToken.AppendChar(c); + } + + var tokenStorageMock = new Mock(); + tokenStorageMock.Setup(x => x.HasToken()).Returns(true); + tokenStorageMock.Setup(x => x.LoadTokenAsync()).ReturnsAsync(secureToken); + + var api = new OctokitGitHubApiClient( + concreteClient, + Mock.Of(), + Mock.Of>(), + Mock.Of(), + tokenStorageMock.Object); + + // Act & Assert + api.IsAuthenticated.Should().BeTrue(); + concreteClient.Credentials.Should().NotBeNull(); + concreteClient.Credentials.Password.Should().Be("stored-secret-pat"); + } + + /// + /// Verifies that ClearAuthenticationToken resets credentials to Anonymous. + /// + [Fact] + public void ClearAuthenticationToken_ResetsCredentialsToAnonymous() + { + // Arrange + var concreteClient = new GitHubClient(new ProductHeaderValue("test")); + var api = new OctokitGitHubApiClient( + concreteClient, + Mock.Of(), + Mock.Of>(), + Mock.Of()); + + var secureToken = new SecureString(); + foreach (char c in "test-token") + { + secureToken.AppendChar(c); + } + + api.SetAuthenticationToken(secureToken); + api.IsAuthenticated.Should().BeTrue(); + + // Act + api.ClearAuthenticationToken(); + + // Assert + api.IsAuthenticated.Should().BeFalse(); + concreteClient.Credentials.Should().Be(Credentials.Anonymous); + } + + /// + /// Verifies that rate limit tracker is updated when RateLimitExceededException occurs. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetLatestReleaseAsync_WhenRateLimitExceeded_UpdatesTrackerAsync() + { + // Arrange + var resetEpoch = ((DateTimeOffset)DateTime.UtcNow.AddMinutes(30)).ToUnixTimeSeconds(); + var headers = new Dictionary + { + ["X-RateLimit-Reset"] = resetEpoch.ToString(), + }; + var responseMock = new Mock(); + responseMock.SetupGet(x => x.Headers).Returns(headers); + var rateLimit = new Octokit.RateLimit(60, 0, resetEpoch); + var apiInfo = new Octokit.ApiInfo(new Dictionary(), new List(), new List(), "etag", rateLimit); + responseMock.SetupGet(x => x.ApiInfo).Returns(apiInfo); + + var rateLimitException = new RateLimitExceededException(responseMock.Object); + + var releasesClientMock = new Mock(); + releasesClientMock + .Setup(x => x.GetLatest(It.IsAny(), It.IsAny())) + .ThrowsAsync(rateLimitException); + + var repositoriesClientMock = new Mock(); + repositoriesClientMock + .SetupGet(x => x.Release) + .Returns(releasesClientMock.Object); + + var gitHubClientMock = new Mock(); + gitHubClientMock.SetupGet(x => x.Repository).Returns(repositoriesClientMock.Object); + + var tracker = new GitHubRateLimitTracker(Mock.Of>()); + + var api = new OctokitGitHubApiClient( + gitHubClientMock.Object, + Mock.Of(), + Mock.Of>(), + Mock.Of(), + rateLimitTracker: tracker); + + // Act + var result = await api.GetLatestReleaseAsync("owner", "repo"); + + // Assert + result.Should().BeNull(); + api.IsRateLimited.Should().BeTrue(); + tracker.IsAtLimit.Should().BeTrue(); + } } \ No newline at end of file diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index ccbfb52d2..b36fc1f80 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -528,49 +528,45 @@ public async Task> GetOpenPullRequestsAsync(Cance // Track if subscribed PR is still open bool subscribedPrFound = false; - var prTasks = new List>(); foreach (var pr in prsData.EnumerateArray()) { - var prJson = pr.Clone(); - prTasks.Add(Task.Run( - async () => + var prNumber = pr.GetProperty("number").GetInt32(); + var title = pr.GetProperty("title").GetString() ?? GameClientConstants.UnknownVersion; + var branchName = pr.TryGetProperty("head", out var head) + ? head.GetProperty("ref").GetString() ?? "unknown" + : "unknown"; + var author = pr.TryGetProperty("user", out var user) + ? user.GetProperty("login").GetString() ?? "unknown" + : "unknown"; + var state = pr.GetProperty("state").GetString() ?? "open"; + var updatedAt = pr.TryGetProperty("updated_at", out var updatedAtProp) + ? updatedAtProp.GetDateTimeOffset() + : (DateTimeOffset?)null; + + // Only fetch artifacts for the subscribed PR to prevent exhausting rate limits across all open PRs + ArtifactUpdateInfo? latestArtifact = null; + if (SubscribedPrNumber.HasValue && SubscribedPrNumber.Value == prNumber) { - var prNumber = prJson.GetProperty("number").GetInt32(); - var title = prJson.GetProperty("title").GetString() ?? GameClientConstants.UnknownVersion; - var branchName = prJson.TryGetProperty("head", out var head) - ? head.GetProperty("ref").GetString() ?? "unknown" - : "unknown"; - var author = prJson.TryGetProperty("user", out var user) - ? user.GetProperty("login").GetString() ?? "unknown" - : "unknown"; - var state = prJson.GetProperty("state").GetString() ?? "open"; - var updatedAt = prJson.TryGetProperty("updated_at", out var updatedAtProp) - ? updatedAtProp.GetDateTimeOffset() - : (DateTimeOffset?)null; - - // Find latest artifact for this PR - ArtifactUpdateInfo? latestArtifact = await FindLatestArtifactForPrAsync(client, prNumber, cancellationToken); - - return new PullRequestInfo - { - Number = prNumber, - Title = title, - BranchName = branchName, - Author = author, - State = state, - UpdatedAt = updatedAt, - LatestArtifact = latestArtifact, - }; - }, - cancellationToken)); + latestArtifact = await FindLatestArtifactForPrAsync(client, prNumber, cancellationToken); + } + + results.Add(new PullRequestInfo + { + Number = prNumber, + Title = title, + BranchName = branchName, + Author = author, + State = state, + UpdatedAt = updatedAt, + LatestArtifact = latestArtifact, + }); } - var prInfos = await Task.WhenAll(prTasks); - var sortedPrs = prInfos + var sortedPrs = results .OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue) .ToList(); - results.AddRange(sortedPrs); + results = sortedPrs; // Check if subscribed PR is still open subscribedPrFound = results.Any(p => p.Number == SubscribedPrNumber); @@ -897,12 +893,22 @@ public async Task InstallPrArtifactAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { - if (prInfo.LatestArtifact == null) + var artifact = prInfo.LatestArtifact; + if (artifact == null) + { + if (_gitHubTokenStorage != null && await _gitHubTokenStorage.LoadTokenAsync() is { } token) + { + using var client = CreateConfiguredHttpClientWithToken(token); + artifact = await FindLatestArtifactForPrAsync(client, prInfo.Number, cancellationToken); + } + } + + if (artifact == null) { throw new InvalidOperationException($"PR #{prInfo.Number} has no artifacts available"); } - await InstallArtifactAsync(prInfo.LatestArtifact, progress, cancellationToken); + await InstallArtifactAsync(artifact, progress, cancellationToken); } /// diff --git a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/GitHubTopicsDiscoverer.cs b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/GitHubTopicsDiscoverer.cs index ff16b61ee..ee85ccb72 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/GitHubTopicsDiscoverer.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentDiscoverers/GitHubTopicsDiscoverer.cs @@ -163,28 +163,31 @@ public async Task> DiscoverAsync( // Try to get latest release for version info GitHubRelease? latestRelease = null; - try + if (!gitHubApiClient.IsRateLimited) { - // Apply rate limiting - await _rateLimitSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - latestRelease = await gitHubApiClient.GetLatestReleaseAsync( - repo.Owner.Login, - repo.Name, - cancellationToken).ConfigureAwait(false); + // Apply rate limiting + await _rateLimitSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + latestRelease = await gitHubApiClient.GetLatestReleaseAsync( + repo.Owner.Login, + repo.Name, + cancellationToken).ConfigureAwait(false); + } + finally + { + // Add delay before releasing semaphore to maintain rate limit + await Task.Delay(RateLimitDelay, cancellationToken).ConfigureAwait(false); + _rateLimitSemaphore.Release(); + } } - finally + catch (Exception ex) { - // Add delay before releasing semaphore to maintain rate limit - await Task.Delay(RateLimitDelay, cancellationToken).ConfigureAwait(false); - _rateLimitSemaphore.Release(); + logger.LogDebug(ex, "No releases found for {Repo}, will use repo info", repo.FullName); } } - catch (Exception ex) - { - logger.LogDebug(ex, "No releases found for {Repo}, will use repo info", repo.FullName); - } // Create search results (may return multiple for multi-asset releases) var contentResults = CreateSearchResults(repo, latestRelease, topic); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index e7ac16dc3..8904fdb58 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -98,27 +98,37 @@ public async Task> ResolveAsync( var isLatest = string.IsNullOrEmpty(tag) || tag.Equals("latest", StringComparison.OrdinalIgnoreCase); - var release = isLatest - ? await gitHubApiClient.GetLatestReleaseAsync( - owner, - repo, - cancellationToken) - : await gitHubApiClient.GetReleaseByTagAsync( - owner, - repo, - tag, - cancellationToken); - - // Fallback for repositories that only have pre-releases (GetLatestReleaseAsync returns null on GitHub if no stable release) - if (release == null && isLatest) + var release = discoveredItem.GetData(); + if (release == null) { - logger.LogInformation("Latest stable release not found for {Owner}/{Repo}. Falling back to most recent release (including pre-releases).", owner, repo); - var allReleases = await gitHubApiClient.GetReleasesAsync(owner, repo, cancellationToken); - release = allReleases?.OrderByDescending(r => r.PublishedAt ?? r.CreatedAt).FirstOrDefault(); + release = isLatest + ? await gitHubApiClient.GetLatestReleaseAsync( + owner, + repo, + cancellationToken) + : await gitHubApiClient.GetReleaseByTagAsync( + owner, + repo, + tag, + cancellationToken); + + // Fallback for repositories that only have pre-releases (GetLatestReleaseAsync returns null on GitHub if no stable release) + if (release == null && isLatest) + { + logger.LogInformation("Latest stable release not found for {Owner}/{Repo}. Falling back to most recent release (including pre-releases).", owner, repo); + var allReleases = await gitHubApiClient.GetReleasesAsync(owner, repo, cancellationToken); + release = allReleases?.OrderByDescending(r => r.PublishedAt ?? r.CreatedAt).FirstOrDefault(); + } } if (release == null) { + if (gitHubApiClient.IsRateLimited) + { + return OperationResult.CreateFailure( + $"GitHub API rate limit exceeded while resolving {owner}/{repo}. Please configure a GitHub Personal Access Token in Settings or try again later."); + } + var errorTag = isLatest ? "latest stable" : $"tag '{tag}'"; return OperationResult.CreateFailure($"Release not found for {owner}/{repo} with {errorTag}"); } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml index 1b599e7d1..ac0a9ae00 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/Wizard/SetupWizardView.axaml @@ -59,7 +59,7 @@