diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323290891..93b5f3ea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,11 +264,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Cache NuGet Packages uses: actions/cache@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89588dd19..8d424e2c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -234,9 +234,6 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install Linux Dependencies - run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Extract Build Info id: buildinfo run: | diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs index e7a543023..5ee0581e1 100644 --- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs +++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs @@ -10,6 +10,21 @@ public static class AppUpdateConstants /// public const int MaxHttpRetries = 3; + /// + /// Index for the Update tab in update notification views. + /// + public const int UpdateTabIndex = 0; + + /// + /// Index for the Browse Builds tab in update notification views. + /// + public const int BrowseBuildsTabIndex = 1; + + /// + /// Maximum valid tab index in update notification views. + /// + public const int MaxTabIndex = 1; + /// /// Velopack directory name. /// @@ -153,6 +168,101 @@ public static class AppUpdateConstants "3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" + "Update available: v{1}"; + /// + /// Update available notification title for release channel. + /// + public const string UpdateAvailableNotificationTitle = "Update Available"; + + /// + /// Update available notification title for branch subscriptions. + /// + public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available"; + + /// + /// Update available notification title for PR subscriptions. + /// + public const string PrUpdateAvailableNotificationTitle = "PR Update Available"; + + /// + /// Update action button text. + /// + public const string UpdateAction = "Update"; + + /// + /// Title for the update in progress notification. + /// + public const string UpdatingAppNotificationTitle = "Updating GenHub"; + + /// + /// Starting update progress message. + /// + public const string UpdateStartingMessage = "Starting update..."; + + /// + /// Title for update failed notification. + /// + public const string UpdateFailedNotificationTitle = "Update Failed"; + + /// + /// Update failed notification body format string ({0}: error message). + /// + public const string UpdateFailedNotificationFormat = "Failed to install update: {0}"; + + /// + /// View updates action button text. + /// + public const string ViewUpdatesAction = "View Updates"; + + /// + /// Release update notification body format string ({0}: version). + /// + public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available."; + + /// + /// Branch update notification body format string ({0}: version, {1}: branch name). + /// + public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'."; + + /// + /// PR update notification body format string ({0}: version, {1}: PR number). + /// + public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}."; + + /// + /// Sort option: sort by last updated date descending. + /// + public const string SortOptionLastUpdated = "Last Updated"; + + /// + /// Sort option: sort by pull request number descending. + /// + public const string SortOptionPrNumberDesc = "PR Number (Highest)"; + + /// + /// Sort option: sort by pull request number ascending. + /// + public const string SortOptionPrNumberAsc = "PR Number (Lowest)"; + + /// + /// Default interval in minutes for periodic update checks (30 minutes). + /// + public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30; + + /// + /// Minimum interval in minutes for periodic update checks (5 minutes). + /// + public const int MinPeriodicUpdateCheckIntervalMinutes = 5; + + /// + /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days). + /// + public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080; + + /// + /// Increment step in minutes for periodic update check interval setting (5 minutes). + /// + public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5; + /// /// Delay before exit after applying update (5 seconds). /// diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index 4b0821443..30cd69c4f 100644 --- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs @@ -1,8 +1,13 @@ namespace GenHub.Core.Constants; /// -/// Constants for command line arguments and URI schemes. +/// Constants for command line arguments and the genhub:// URI scheme. /// +/// +/// Subscription links use genhub://subscribe?url=<absolute-url>. +/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share +/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time. +/// public static class CommandLineConstants { /// @@ -16,22 +21,27 @@ public static class CommandLineConstants public const string LaunchProfileInlinePrefix = "--launch-profile="; /// - /// URI scheme used for protocol handling. + /// Scheme name for custom protocol registration. /// - public const string UriScheme = "genhub://"; + public const string SchemeName = "genhub"; /// - /// Command for subscribing to a catalog via URI. + /// Custom URI scheme registered so OS/browser links can open GenHub. + /// + public const string UriScheme = SchemeName + "://"; + + /// + /// URI path segment for content subscription (genhub://subscribe?url=...). /// public const string SubscribeCommand = "subscribe"; /// - /// Full prefix for subscription URI. + /// Full prefix for subscription URIs (genhub://subscribe). /// public const string SubscribeUriPrefix = UriScheme + SubscribeCommand; /// - /// Query parameter name for the catalog URL in a subscription URI. + /// Query parameter carrying the absolute URL of a catalog (or future provider definition). /// public const string SubscribeUrlParam = "?url="; } diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs index 1a66630f8..4096fd317 100644 --- a/GenHub/GenHub.Core/Constants/IpcCommands.cs +++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs @@ -11,7 +11,8 @@ public static class IpcCommands public const string LaunchProfilePrefix = "launch-profile:"; /// - /// Command prefix used to subscribe to a catalog via IPC. + /// Command prefix used to forward a subscribe URL to the primary instance + /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=.... /// public const string SubscribePrefix = "subscribe:"; } diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs index b15606e59..d5d3dffce 100644 --- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs +++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs @@ -60,6 +60,21 @@ public static class SuperHackersConstants /// public const string GeneralsGameCodeRepo = "GeneralsGameCode"; + /// + /// GitHub owner for Generals game patch 2. + /// + public const string GeneralsGamePatch2Owner = "TheSuperHackers"; + + /// + /// GitHub repo for Generals game patch 2. + /// + public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2"; + + /// + /// Display name for Generals game patch 2. + /// + public const string GeneralsGamePatch2DisplayName = "Community Patch 2"; + // ===== Service Configuration ===== /// diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs new file mode 100644 index 000000000..b44ff9c53 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Helper class for application update version comparison and parsing. +/// +public static partial class AppUpdateVersionHelper +{ + /// + /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641). + /// Returns 0 for plain semantic versions without CI run markers. + /// + /// The version string to extract the run number from. + /// The extracted run number, or 0 if extraction fails or not a CI build. + public static int ExtractRunNumber(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return 0; + } + + var match = CiRunNumberRegex().Match(version); + if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber) && runNumber > 0) + { + return runNumber; + } + + var ciMatch = CiMarkerRegex().Match(version); + if (ciMatch.Success && int.TryParse(ciMatch.Groups[1].Value, out var ciRunNumber) && ciRunNumber > 0) + { + return ciRunNumber; + } + + return 0; + } + + /// + /// Checks whether an available artifact version is newer than the currently installed version. + /// + /// The new artifact version string. + /// The current version string. + /// True if newVersion is newer than currentVersion; otherwise false. + public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion) + { + if (string.IsNullOrWhiteSpace(newVersion)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(currentVersion)) + { + return true; + } + + var newVersionBase = newVersion.Split('+')[0].Trim(); + var currentVersionBase = currentVersion.Split('+')[0].Trim(); + + var newRun = ExtractRunNumber(newVersionBase); + var currentRun = ExtractRunNumber(currentVersionBase); + + if (newRun > 0 && currentRun > 0) + { + return newRun > currentRun; + } + + if (newRun == 0 && currentRun > 0) + { + return false; + } + + if (newRun > 0 && currentRun == 0) + { + return true; + } + + var newClean = newVersionBase.Split('-')[0]; + var currentClean = currentVersionBase.Split('-')[0]; + if (Version.TryParse(newClean, out var newVer) && Version.TryParse(currentClean, out var currentVer)) + { + return newVer > currentVer; + } + + return false; + } + + /// + /// Regex for extracting workflow run number from a 0.0.X CI version string. + /// Matches patterns like "0.0.1282-pr265", "0.0.1282-main", "0.0.1282". + /// + [GeneratedRegex(@"^0\.0\.(\d+)(?:-[a-zA-Z0-9_.-]+)?$", RegexOptions.IgnoreCase)] + private static partial Regex CiRunNumberRegex(); + + /// + /// Regex for extracting workflow run number from a -ci.X marker. + /// + [GeneratedRegex(@"-ci\.(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex CiMarkerRegex(); +} diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index f6b570af0..d5c595d36 100644 --- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs +++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs @@ -15,9 +15,9 @@ public static class CommandLineParser /// The extracted profile identifier if present; otherwise, null. public static string? ExtractProfileId(string[] args) { - for (var i = 0; i < args.Length; i++) + for (int i = 0; i < args.Length; i++) { - var arg = args[i]; + string arg = args[i]; if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { @@ -34,23 +34,48 @@ public static class CommandLineParser } /// - /// Extracts a subscription URL from command line arguments. - /// Supports the URI scheme format: genhub://subscribe?url=<url>. + /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument. /// + /// + /// The returned value is the url query value only (not the genhub:// wrapper). + /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider + /// Definition URL without changing this parser. + /// /// The command line arguments. - /// The extracted catalog URL if present; otherwise, null. + /// The decoded absolute URL if present; otherwise, null. public static string? ExtractSubscriptionUrl(string[] args) { - foreach (var arg in args) + foreach (string arg in args) { if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase)) { - // Simple parsing for ?url=... - var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); + string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..]; + if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal)) + { + continue; + } + + int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); if (queryStart != -1) { - var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; - return Uri.UnescapeDataString(url).Trim('"'); + string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; + string unescaped = Uri.UnescapeDataString(url) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim('"', '\'', ' ', '\t'); + + if (string.IsNullOrWhiteSpace(unescaped)) + { + return null; + } + + if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return unescaped; + } + + return null; } } } diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs index d97f2f8cf..93a3536b0 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs @@ -64,6 +64,18 @@ public interface IConfigurationProviderService /// True if auto-check is enabled; otherwise, false. bool GetAutoCheckForUpdatesOnStartup(); + /// + /// Gets whether to automatically check for updates periodically. + /// + /// True if periodic auto-check is enabled; otherwise, false. + bool GetAutoCheckForUpdatesPeriodically(); + + /// + /// Gets the interval in minutes for periodic update checks. + /// + /// The update check interval in minutes. + int GetPeriodicUpdateCheckIntervalMinutes(); + /// /// Gets whether detailed logging is enabled. /// diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs index 84a4d3773..9b26f26b2 100644 --- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs +++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs @@ -20,6 +20,7 @@ public interface ILocalContentService /// Optional original source path of the content. /// Optional progress reporter for tracking manifest creation. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the created manifest or errors. Task> CreateLocalContentManifestAsync( string directoryPath, @@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Adds local content by creating and storing a manifest. @@ -66,6 +68,7 @@ Task> AddLocalContentAsync( /// Optional original source path of the content. /// Optional progress reporter. /// Cancellation token. + /// Optional relative path of the main executable entry point. /// A result containing the updated manifest. Task> UpdateLocalContentManifestAsync( string existingManifestId, @@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync( GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + string? entryPoint = null); /// /// Gets the allowed content types for local content creation. diff --git a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs index 34a88df40..55800dd3e 100644 --- a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs +++ b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs @@ -28,6 +28,11 @@ public interface INotificationService /// IObservable NotificationHistory { get; } + /// + /// Gets the observable stream of notification update requests. + /// + IObservable<(Guid Id, string? Title, string Message)> UpdateRequests { get; } + /// /// Shows an informational notification. /// @@ -70,6 +75,14 @@ public interface INotificationService /// The notification to show. void Show(NotificationMessage notification); + /// + /// Updates the message and optionally the title of an active notification. + /// + /// The ID of the notification to update. + /// The new message content. + /// Optional new title. If null, the existing title is preserved. + void Update(Guid notificationId, string message, string? title = null); + /// /// Dismisses a specific notification. /// diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs new file mode 100644 index 000000000..e199ff5b3 --- /dev/null +++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Messages; + +/// +/// Message sent when update settings have changed. +/// +/// Whether to check for updates on startup. +/// Whether to check for updates periodically. +/// Interval in minutes between periodic update checks. +public record UpdateSettingsChangedMessage( + bool AutoCheckForUpdatesOnStartup, + bool AutoCheckForUpdatesPeriodically, + int PeriodicUpdateCheckIntervalMinutes); diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs index a8d2a9693..e9fc4bb56 100644 --- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs +++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs @@ -50,6 +50,11 @@ public record PullRequestInfo /// public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}"; + /// + /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title"). + /// + public string DisplayTitle => $"#{Number} - {Title}"; + /// /// Gets a value indicating whether this PR is still open. /// diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index 4b77fe175..c33263307 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -39,6 +39,12 @@ public class UserSettings /// Gets or sets a value indicating whether to automatically check for updates on startup. public bool AutoCheckForUpdatesOnStartup { get; set; } = true; + /// Gets or sets a value indicating whether to automatically check for updates periodically. + public bool AutoCheckForUpdatesPeriodically { get; set; } = true; + + /// Gets or sets the interval in minutes between periodic update checks. + public int PeriodicUpdateCheckIntervalMinutes { get; set; } = GenHub.Core.Constants.AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + /// Gets or sets the timestamp of the last update check in ISO 8601 format. public string? LastUpdateCheckTimestamp { get; set; } @@ -151,6 +157,8 @@ public UserSettings Clone() MaxConcurrentDownloads = MaxConcurrentDownloads, AllowBackgroundDownloads = AllowBackgroundDownloads, AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes, LastUpdateCheckTimestamp = LastUpdateCheckTimestamp, EnableDetailedLogging = EnableDetailedLogging, DefaultWorkspaceStrategy = DefaultWorkspaceStrategy, diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs index 83d681312..7a93f17cf 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs @@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest; public sealed class ManifestIdJsonConverter : JsonConverter { /// - public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138 { var s = reader.GetString() ?? string.Empty; return ManifestId.Create(s); diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs index ddb22794a..fc609db20 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs @@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint( files); } - private static bool PathsMatch(string left, string right) => + /// + /// Determines whether two relative file paths match, normalizing directory separators and leading slashes. + /// + /// The first relative path. + /// The second relative path. + /// true if the paths match; otherwise, false. + public static bool PathsMatch(string left, string right) => string.Equals( left.Replace('\\', '/').TrimStart('/'), right.Replace('\\', '/').TrimStart('/'), diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs index 24058e7d9..04375f0ea 100644 --- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs +++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs @@ -15,7 +15,8 @@ public class JsonWorkspaceStrategyConverter : JsonConverter /// [SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] [SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] - public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + [SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")] + public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138 { if (reader.TokenType == JsonTokenType.Number) { diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs index 8f544eec5..2912aa9a5 100644 --- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs +++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs @@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { @@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs var manifest = builder.Build(); manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath; + if (!string.IsNullOrWhiteSpace(entryPoint)) + { + var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/'); + + var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == "..")) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..')."); + } + + var matchedFile = manifest.Files.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint)); + + if (matchedFile == null) + { + return OperationResult.CreateFailure( + $"Entry point '{entryPoint}' was not found among the files in the directory."); + } + + manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/'); + } + // Auto-add GameInstallation dependency for GameClient content types // This ensures auto-resolution logic works correctly for locally added clients if (contentType == ContentType.GameClient) @@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs GameType targetGame, string? sourcePath = null, IProgress? progress = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? entryPoint = null) { try { // 1. Create the new manifest/content // We do this FIRST to ensure the new content is valid before deleting the old one - var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken); + var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint); if (!createResult.Success) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs index 49836a2dc..7cd571b77 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -549,6 +549,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue) Assert.Equal(userValue, result); } + /// + /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set. + /// + /// The value to set for AutoCheckForUpdatesPeriodically in user settings. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue) + { + // Arrange + var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically)); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetAutoCheckForUpdatesPeriodically(); + + // Assert + Assert.Equal(userValue, result); + } + + /// + /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set. + /// + /// The interval to set in user settings. + /// The expected clamped interval. + [Theory] + [InlineData(60, 60)] + [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)] + [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)] + public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes) + { + // Arrange + var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes }; + if (intervalMinutes > 0) + { + userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes)); + } + + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetPeriodicUpdateCheckIntervalMinutes(); + + // Assert + Assert.Equal(expectedMinutes, result); + } + /// /// Verifies that GetEnableDetailedLogging returns user setting when explicitly set. /// @@ -784,7 +836,8 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults() // Assert Assert.Contains("TheSuperHackers/GeneralsGameCode", result); - Assert.Single(result); + Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result); + Assert.Equal(2, result.Count); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs index 9b37967c3..1e105e2ef 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs @@ -66,6 +66,8 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings() Assert.Equal(DownloadDefaults.MaxConcurrentDownloads, settings.MaxConcurrentDownloads); Assert.True(settings.AllowBackgroundDownloads); Assert.True(settings.AutoCheckForUpdatesOnStartup); + Assert.True(settings.AutoCheckForUpdatesPeriodically); + Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, settings.PeriodicUpdateCheckIntervalMinutes); Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, settings.DefaultWorkspaceStrategy); } @@ -364,6 +366,25 @@ public void UpdateSettings_EnableDetailedLogging_CanBeSetAndRetrieved(bool enabl Assert.Equal(enableLogging, currentSettings.EnableDetailedLogging); } + /// + /// Verifies that periodic update settings can be set and retrieved correctly. + /// + [Fact] + public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved() + { + var service = CreateService(); + + service.Update(settings => + { + settings.AutoCheckForUpdatesPeriodically = false; + settings.PeriodicUpdateCheckIntervalMinutes = 15; + }); + var currentSettings = service.Get(); + + Assert.False(currentSettings.AutoCheckForUpdatesPeriodically); + Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes); + } + private static IAppConfiguration CreateAppConfigMock() { var appConfig = new Mock(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs new file mode 100644 index 000000000..be8cebf08 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs @@ -0,0 +1,95 @@ +using System; +using GenHub.Core.Constants; +using Xunit; + +namespace GenHub.Tests.Core.Constants; + +/// +/// Unit tests for . +/// +public class AppUpdateConstantsTests +{ + /// + /// Tests that tab index constants have expected values. + /// + [Fact] + public void TabIndex_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(0, AppUpdateConstants.UpdateTabIndex); + Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex); + Assert.Equal(1, AppUpdateConstants.MaxTabIndex); + } + + /// + /// Tests that platform and artifact prefix constants have expected values. + /// + [Fact] + public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues() + { + Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory); + Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows); + Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux); + Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease); + Assert.Equal("windows", AppUpdateConstants.PlatformWindows); + Assert.Equal("linux", AppUpdateConstants.PlatformLinux); + } + + /// + /// Tests that periodic update check interval constants have expected values. + /// + [Fact] + public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes); + Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes); + Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Tests that timespan constants have expected durations. + /// + [Fact] + public void TimeSpan_Constants_ShouldHaveExpectedValues() + { + Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay); + Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration); + Assert.Equal(3, AppUpdateConstants.MaxHttpRetries); + } + + /// + /// Tests that notification title and format constants are non-empty strings. + /// + [Fact] + public void NotificationAndFormat_Constants_ShouldBeValid() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction)); + Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat); + } + + /// + /// Tests that sort option constants are distinct non-empty strings. + /// + [Fact] + public void SortOption_Constants_ShouldBeDistinctAndNonEmpty() + { + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc)); + Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc); + Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs index fed2373b8..b4ea11d25 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs @@ -1,8 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; using GenHub.Features.AppUpdate.Interfaces; using GenHub.Features.AppUpdate.ViewModels; using Microsoft.Extensions.Logging; using Moq; +using Xunit; namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels; @@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn .ReturnsAsync((Velopack.UpdateInfo?)null); var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( mockVelopack.Object, @@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn public void Constructor_InitializesSuccessfully() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully() public void IsCheckButtonEnabled_ReflectsCheckingState() { var mockUserSettings = new Mock(); - mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings()); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); var vm = new UpdateNotificationViewModel( Mock.Of(), @@ -72,4 +79,493 @@ public void IsCheckButtonEnabled_ReflectsCheckingState() Assert.True(vm.IsCheckButtonEnabled); } + + /// + /// Verifies that pull request display title formats properly with PR number and title. + /// + [Fact] + public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle() + { + var prInfo = new PullRequestInfo + { + Number = 265, + Title = "feat: UI Downloads", + BranchName = "feat/ui-downloads", + Author = "developer", + State = "open", + UpdatedAt = DateTimeOffset.UtcNow, + }; + + Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle); + } + + /// + /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024), + }; + + var loadTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => loadTcs.TrySetCanceled(ct)); + return await loadTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(389); + + Assert.True(vm.IsLoadingVersions); + loadTcs.SetResult(artifacts); + + // wait briefly for async continuation + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Equal(2, vm.AvailableVersions.Count); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal("e1212a5", vm.SelectedVersion.GitHash); + Assert.True(vm.CanDownloadUpdate); + } + + /// + /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var artifacts = new List + { + new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048), + }; + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny())) + .ReturnsAsync(artifacts); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("development"); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version); + } + + /// + /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var pr391Tcs = new TaskCompletionSource>(); + var pr389Tcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr391Tcs.TrySetCanceled(ct)); + return await pr391Tcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => pr389Tcs.TrySetCanceled(ct)); + return await pr389Tcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + // subscribe to 391 first + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + // immediately switch to 389 while 391 is loading + vm.SubscribeToPrCommand.Execute(389); + + // resolve 389 artifacts + var pr389Artifacts = new List + { + new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + }; + pr389Tcs.TrySetResult(pr389Artifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(pr391Tcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version); + Assert.Equal(389, vm.SelectedVersion.PullRequestNumber); + } + + /// + /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var branchOldTcs = new TaskCompletionSource>(); + var branchNewTcs = new TaskCompletionSource>(); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchOldTcs.TrySetCanceled(ct)); + return await branchOldTcs.Task; + }); + + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny())) + .Returns(async (string _, CancellationToken ct) => + { + ct.Register(() => branchNewTcs.TrySetCanceled(ct)); + return await branchNewTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("old-branch"); + Assert.True(vm.IsLoadingVersions); + + vm.SubscribeToBranchCommand.Execute("new-branch"); + + var newArtifacts = new List + { + new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048), + }; + branchNewTcs.TrySetResult(newArtifacts); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(branchOldTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Single(vm.AvailableVersions); + Assert.NotNull(vm.SelectedVersion); + Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version); + } + + /// + /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var prTcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny())) + .Returns(async (int _, CancellationToken ct) => + { + ct.Register(() => prTcs.TrySetCanceled(ct)); + return await prTcs.Task; + }); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToPrCommand.Execute(391); + Assert.True(vm.IsLoadingVersions); + + vm.UnsubscribeCommand.Execute(null); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.True(prTcs.Task.IsCanceled); + Assert.False(vm.IsLoadingVersions); + Assert.Empty(vm.AvailableVersions); + Assert.Null(vm.SelectedVersion); + } + + /// + /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers. + /// + /// The PR number under test. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber) + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + // verify command execution does not throw + vm.OpenPullRequestUrlCommand.Execute(prNumber); + Assert.NotNull(vm); + } + + /// + /// Verifies that changing the sort option reorders available pull requests accordingly. + /// + [Fact] + public void SelectedSortOption_ReordersAvailablePullRequests() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var now = DateTimeOffset.UtcNow; + var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) }; + var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) }; + var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now }; + + vm.AvailablePullRequests.Add(pr100); + vm.AvailablePullRequests.Add(pr200); + vm.AvailablePullRequests.Add(pr300); + + // sort by PR number descending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(100, vm.AvailablePullRequests[2].Number); + + // sort by PR number ascending + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc; + Assert.Equal(100, vm.AvailablePullRequests[0].Number); + Assert.Equal(200, vm.AvailablePullRequests[1].Number); + Assert.Equal(300, vm.AvailablePullRequests[2].Number); + + // sort by last updated (newest first) + vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated; + Assert.Equal(300, vm.AvailablePullRequests[0].Number); + Assert.Equal(100, vm.AvailablePullRequests[1].Number); + Assert.Equal(200, vm.AvailablePullRequests[2].Number); + } + + /// + /// Verifies that tab commands correctly switch between Update and Browse Builds tabs. + /// + [Fact] + public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.ShowBrowseBuildsTabCommand.Execute(null); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.ShowUpdateTabCommand.Execute(null); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute("1"); + Assert.Equal(1, vm.SelectedTabIndex); + Assert.True(vm.IsBrowseTabSelected); + + vm.SelectTabCommand.Execute(0); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.False(vm.IsBrowseTabSelected); + + // Clamping out-of-range inputs + vm.SelectTabCommand.Execute(-1); + Assert.Equal(0, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute(5); + Assert.Equal(1, vm.SelectedTabIndex); + + vm.SelectTabCommand.Execute("99"); + Assert.Equal(1, vm.SelectedTabIndex); + } + + /// + /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string. + /// + [Fact] + public void DisplayCurrentVersion_ReturnsNonEmptyVersion() + { + var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion; + Assert.False(string.IsNullOrWhiteSpace(displayVersion)); + Assert.StartsWith("v", displayVersion); + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + Assert.Equal(displayVersion, vm.InstalledVersionDisplay); + } + + /// + /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true. + /// + [Fact] + public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object); + + var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = newerArtifact; + + Assert.True(vm.IsUpdateAvailable); + Assert.Equal("0.0.99999-pr389", vm.LatestVersion); + Assert.Contains("0.0.99999-pr389", vm.StatusMessage); + } + + /// + /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl. + /// + [Fact] + public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" }); + + var vm = new UpdateNotificationViewModel( + Mock.Of(), + Mock.Of>(), + mockUserSettings.Object) + { + IsUpdateAvailable = true, + LatestVersion = "0.0.88888", + ReleaseNotesUrl = "https://example.com/notes", + }; + + var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024); + vm.SelectedVersion = dismissedArtifact; + + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message. + /// + [Fact] + public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object) + { + SubscribedPr = new PullRequestInfo + { + Number = 389, + Title = "Test PR", + BranchName = "feature/test", + Author = "testuser", + State = "open", + }, + SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024), + IsUpdateAvailable = true, + LatestVersion = "0.0.99999-pr389", + ReleaseNotesUrl = "https://example.com/notes", + }; + + vm.UnsubscribeCommand.Execute(null); + + Assert.Null(vm.SubscribedPr); + Assert.Null(vm.SubscribedBranch); + Assert.Null(vm.SelectedVersion); + Assert.False(vm.IsUpdateAvailable); + Assert.Empty(vm.LatestVersion); + Assert.Empty(vm.ReleaseNotesUrl); + Assert.False(string.IsNullOrEmpty(vm.StatusMessage)); + Assert.Null(mockVelopack.Object.SubscribedPrNumber); + } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs index 5422334ce..33d4f7d2a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate await Assert.ThrowsAnyAsync( () => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token)); } + + /// + /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync() + { + // Arrange + var specializedProviderMock = new Mock(); + var githubProviderMock = new Mock(); + + const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2"; + + var specializedResult = new ContentSearchResult + { + Id = duplicateId, + Name = "TheSuperHackers Patch 2", + ProviderName = "thesuperhackers", + }; + + var githubResult = new ContentSearchResult + { + Id = duplicateId, + Name = "GeneralsGamePatch2", + ProviderName = "GitHub", + }; + + specializedProviderMock.Setup(p => p.IsEnabled).Returns(true); + specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult])); + + githubProviderMock.Setup(p => p.IsEnabled).Returns(true); + githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([githubResult])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [githubProviderMock.Object, specializedProviderMock.Object], + [], + [], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.SearchAsync(new ContentSearchQuery()); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("thesuperhackers", items[0].ProviderName); + Assert.Equal("TheSuperHackers Patch 2", items[0].Name); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs new file mode 100644 index 000000000..d8f2d1692 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Services.Content; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services; + +/// +/// Contains tests for . +/// +public class LocalContentServiceTests : IDisposable +{ + private readonly Mock _manifestGenServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _reconciliationServiceMock; + private readonly LocalContentService _service; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public LocalContentServiceTests() + { + _manifestGenServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _reconciliationServiceMock = new Mock(); + + _service = new LocalContentService( + _manifestGenServiceMock.Object, + _contentStorageServiceMock.Object, + _reconciliationServiceMock.Object, + NullLogger.Instance); + + _tempDir = Path.Combine(Path.GetTempPath(), "LocalContentServiceTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up temporary resources. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup failures + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that CreateLocalContentManifestAsync sets EntryPoint when provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithEntryPoint_SetsManifestEntryPoint() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: "FinalBIG.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("FinalBIG.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync normalizes backslashes to forward slashes in EntryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_NormalizesBackslashesInEntryPoint() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "bin/sub/tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "bin\\sub\\tool.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("bin/sub/tool.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when passed a whitespace-only value. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithWhitespaceOnlyEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: " "); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects rooted or parent-traversal entry points. + /// + /// The invalid entry point path to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("/usr/bin/tool.exe")] + [InlineData("../tool.exe")] + [InlineData("bin/../../tool.exe")] + public async Task CreateLocalContentManifestAsync_WithInvalidEntryPointPath_ReturnsFailure(string invalidEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: invalidEntryPoint); + + Assert.False(result.Success); + Assert.Contains("invalid", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync accepts entry points with double dots in file or folder names. + /// + /// The valid entry point path with dots in name. + /// A task representing the asynchronous test. + [Theory] + [InlineData("game..exe")] + [InlineData("backup..old/tool.exe")] + public async Task CreateLocalContentManifestAsync_WithDoubleDotsInName_ReturnsSuccess(string validEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", validEntryPoint); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: validEntryPoint); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(validEntryPoint, result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects an entry point that does not exist in manifest files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithNonExistentEntryPoint_ReturnsFailure() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "missing.exe"); + + Assert.False(result.Success); + Assert.Contains("not found", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when not provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithoutEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.Mod, GameType.ZeroHour, "MyMod"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "MyMod", + contentType: ContentType.Mod, + targetGame: GameType.ZeroHour); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that UpdateLocalContentManifestAsync passes entryPoint through to the created manifest. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateLocalContentManifestAsync_WithEntryPoint_SetsEntryPointOnUpdatedManifest() + { + SetupManifestBuilder(ContentType.GameClient, GameType.ZeroHour, "GeneralsClient", "generals.exe"); + + _reconciliationServiceMock + .Setup(x => x.OrchestrateLocalUpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentUpdateResult())); + + var result = await _service.UpdateLocalContentManifestAsync( + existingManifestId: "1.0.local.gameclient.old", + name: "GeneralsClient", + directoryPath: _tempDir, + contentType: ContentType.GameClient, + targetGame: GameType.ZeroHour, + entryPoint: "generals.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("generals.exe", result.Data!.EntryPoint); + } + + private void SetupManifestBuilder(ContentType contentType, GameType targetGame, string contentName, params string[] filePaths) + { + var files = filePaths.Length > 0 + ? filePaths.Select(f => new ManifestFile { RelativePath = f, IsExecutable = f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) }).ToList() + : new List(); + + var manifest = new ContentManifest + { + Id = ManifestId.Create($"1.0.local.{contentType.ToString().ToLowerInvariant()}.{contentName.ToLowerInvariant()}"), + Name = contentName, + ContentType = contentType, + TargetGame = targetGame, + Files = files, + }; + + var builderMock = new Mock(); + builderMock.Setup(b => b.Build()).Returns(manifest); + + _manifestGenServiceMock + .Setup(x => x.CreateContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(builderMock.Object); + + _contentStorageServiceMock + .Setup(x => x.StoreContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs new file mode 100644 index 000000000..522569cd5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.Publishers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Publishers; + +/// +/// Unit tests for . +/// +public class PublisherManifestFactoryResolverTests +{ + private readonly Mock _hashProviderMock; + + /// + /// Initializes a new instance of the class. + /// + public PublisherManifestFactoryResolverTests() + { + _hashProviderMock = new Mock(); + } + + /// + /// Verifies that ResolveFactory returns the specialized factory when CanHandle matches. + /// + [Fact] + public void ResolveFactory_ReturnsSpecializedFactory_WhenCanHandleMatches() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory, gitHubFactory], + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.thesuperhackers.gameclient.generals"), + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo + { + Name = "TheSuperHackers", + PublisherType = PublisherTypeConstants.TheSuperHackers, + }, + }; + + // Act + var result = resolver.ResolveFactory(manifest); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + /// + /// Verifies that ResolveFactory falls back to GitHubManifestFactory for non-GameClient publisher content. + /// + [Fact] + public void ResolveFactory_FallsBackToGitHubFactory_WhenSpecializedFactoryCannotHandle() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory, gitHubFactory], + NullLogger.Instance); + + var patchManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.thesuperhackers.patch.generalsgamepatch2"), + ContentType = ContentType.Patch, + Publisher = new PublisherInfo + { + Name = "TheSuperHackers", + PublisherType = PublisherTypeConstants.TheSuperHackers, + }, + }; + + // Act + var result = resolver.ResolveFactory(patchManifest); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + /// + /// Verifies that ResolveFactory returns null when no specialized or fallback factory is available. + /// + [Fact] + public void ResolveFactory_ReturnsNull_WhenNoFactoryMatchesAndNoFallbackAvailable() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory], + NullLogger.Instance); + + var patchManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.testpublisher.mod.sample"), + ContentType = ContentType.Mod, + Publisher = new PublisherInfo + { + Name = "Unknown", + PublisherType = "unknown", + }, + }; + + // Act + var result = resolver.ResolveFactory(patchManifest); + + // Assert + Assert.Null(result); + } + + /// + /// Verifies that ResolveFactory returns null when a GameClient manifest has no specialized factory, + /// rather than falling back to GitHubManifestFactory. + /// + [Fact] + public void ResolveFactory_ReturnsNull_WhenGameClientHasNoSpecializedFactory() + { + // Arrange + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [gitHubFactory], + NullLogger.Instance); + + var gameClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.unknownpublisher.gameclient.generals"), + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo + { + Name = "UnknownPublisher", + PublisherType = "unknownpublisher", + }, + }; + + // Act + var result = resolver.ResolveFactory(gameClientManifest); + + // Assert + Assert.Null(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs new file mode 100644 index 000000000..2b645c6e5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs @@ -0,0 +1,497 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.GitHub; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GitHub; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.Publishers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Publishers; + +/// +/// Unit tests for . +/// +public class SuperHackersProviderTests +{ + private readonly Mock _providerDefinitionLoaderMock; + private readonly Mock _gitHubApiClientMock; + private readonly Mock _resolverMock; + private readonly Mock _delivererMock; + private readonly Mock _validatorMock; + private readonly SuperHackersProvider _provider; + + /// + /// Initializes a new instance of the class. + /// + public SuperHackersProviderTests() + { + _providerDefinitionLoaderMock = new Mock(); + _gitHubApiClientMock = new Mock(); + _resolverMock = new Mock(); + _delivererMock = new Mock(); + _validatorMock = new Mock(); + + _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId); + _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer); + + _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult("test", [])); + + _provider = new SuperHackersProvider( + _providerDefinitionLoaderMock.Object, + _gitHubApiClientMock.Object, + [_resolverMock.Object], + [_delivererMock.Object], + _validatorMock.Object, + NullLogger.Instance); + } + + /// + /// Verifies that SearchAsync returns both GeneralsGameCode and GeneralsGamePatch2 releases when available. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DiscoversBothGameCodeAndGamePatch2_WhenBothAvailableAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease + { + TagName = "weekly-2026-08-01", + Name = "Weekly Release 2026-08-01", + Body = "Generals and Zero Hour game code updates", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGameCode/releases/tag/weekly-2026-08-01", + CreatedAt = DateTimeOffset.UtcNow, + }; + + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Release 1.0.0", + Body = "Community Patch 2 to fix and improve Generals and Zero Hour", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Equal(2, items.Count); + + var gameCodeItem = items.FirstOrDefault(i => i.ContentType == ContentType.GameClient); + Assert.NotNull(gameCodeItem); + Assert.Equal("weekly-2026-08-01", gameCodeItem.Version); + Assert.Equal(SuperHackersConstants.GeneralsGameCodeRepo, gameCodeItem.ResolverMetadata[GitHubConstants.RepoMetadataKey]); + + var gamePatch2Item = items.FirstOrDefault(i => i.ContentType == ContentType.Patch); + Assert.NotNull(gamePatch2Item); + Assert.Equal("1.0.0", gamePatch2Item.Version); + Assert.Equal(SuperHackersConstants.GeneralsGamePatch2Repo, gamePatch2Item.ResolverMetadata[GitHubConstants.RepoMetadataKey]); + } + + /// + /// Verifies that SearchAsync filters properly by repository search term. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersBySearchTerm_CorrectlyAsync() + { + // Arrange + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Release 1.0.0", + Body = "Community Patch 2", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { SearchTerm = "GeneralsGamePatch2" }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync filters by ContentType correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByContentType_ReturnsOnlyMatchingReleasesAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + Assert.Equal("1.0.0", items[0].Version); + } + + /// + /// Verifies that SearchAsync filters by TargetGame correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByTargetGame_ReturnsMatchingReleasesAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var zeroHourQuery = new ContentSearchQuery { TargetGame = GameType.ZeroHour }; + + // Act + var result = await _provider.SearchAsync(zeroHourQuery); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + Assert.Equal(GameType.ZeroHour, items[0].TargetGame); + } + + /// + /// Verifies that SearchAsync filters by author name and github author correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByAuthor_ReturnsEmptyWhenAuthorDoesNotMatchAsync() + { + // Arrange + var query = new ContentSearchQuery { AuthorName = "NonExistentAuthor" }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Empty(items); + } + + /// + /// Verifies that SearchAsync matches on display name and body text. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_MatchesSearchTerm_OnDisplayNameAndBodyAsync() + { + // Arrange + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Patch Release", + Body = "Community patch details", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1", Body = "Engine updates" }); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { SearchTerm = SuperHackersConstants.GeneralsGamePatch2DisplayName }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync returns failure when one target returns null release and the other throws an error. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_WhenOneTargetReturnsNullAndOtherErrors_ReturnsFailureAsync() + { + // Arrange + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API rate limit")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.False(result.Success); + Assert.Contains("Search failed for SuperHackers targets", result.FirstError); + } + + /// + /// Verifies that SearchAsync returns successful results when one repository fails. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_ReturnsRemainingReleases_WhenOneRepositoryFailsAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API error")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.GameClient, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync returns failure when all matching repositories fail. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_ReturnsFailure_WhenAllRepositoriesFailAsync() + { + // Arrange + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Network failure 1")); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Network failure 2")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.False(result.Success); + Assert.Contains("Search failed for SuperHackers targets", result.FirstError); + } + + /// + /// Verifies that SearchAsync propagates cancellation. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_PropagatesCancellation_WhenCancellationRequestedAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => _provider.SearchAsync(new ContentSearchQuery(), cts.Token)); + } + + /// + /// Verifies that SearchAsync falls back to display name and tag name when release name is blank. + /// + /// The candidate release name to test. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SearchAsync_UsesFallbackName_WhenReleaseNameIsBlankAsync(string? releaseName) + { + // Arrange + var release = new GitHubRelease + { + TagName = "alpha-4", + Name = releaseName ?? string.Empty, + Body = "Patch notes", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(release); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal($"{SuperHackersConstants.GeneralsGamePatch2DisplayName} alpha-4", items[0].Name); + } + + /// + /// Verifies that SearchAsync preserves the original release name when it is not blank. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_PreservesReleaseName_WhenReleaseNameIsNonBlankAsync() + { + // Arrange + var release = new GitHubRelease + { + TagName = "alpha-4", + Name = "Community Patch 2.0 Alpha 4", + Body = "Patch notes", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(release); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("Community Patch 2.0 Alpha 4", items[0].Name); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index 3afee1b7b..23aea4a92 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -195,7 +195,7 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_Reports Assert.False(result.Success); var errors = string.Join(", ", result.Errors); - Assert.Contains("without starting", errors); + Assert.True(errors.Contains("without starting") || errors.Contains("did not start"), $"Expected start failure message, but got: {errors}"); Assert.Contains(complaint, errors); } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs new file mode 100644 index 000000000..c7490ef60 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs @@ -0,0 +1,799 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.GameProfiles.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels; + +/// +/// Contains tests for . +/// +public class AddLocalContentViewModelTests : IDisposable +{ + private readonly Mock _localContentServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _normalizationServiceMock; + private readonly Mock _dialogServiceMock; + private readonly List _tempDirectories = []; + private readonly List _viewModels = []; + + /// + /// Initializes a new instance of the class. + /// + public AddLocalContentViewModelTests() + { + _localContentServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _normalizationServiceMock = new Mock(); + _dialogServiceMock = new Mock(); + + _localContentServiceMock + .Setup(x => x.AllowedContentTypes) + .Returns(AddLocalContentViewModel.AllowedContentTypes); + + _normalizationServiceMock + .Setup(x => x.DetectGenLauncherFilesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GenLauncherDetectionResult()); + } + + /// + /// Cleans up temporary test directories and viewmodels. + /// + public void Dispose() + { + foreach (var vm in _viewModels) + { + vm.Dispose(); + } + + foreach (var dir in _tempDirectories) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + // Ignore cleanup errors + } + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that the ViewModel initializes with proper defaults. + /// + [Fact] + public void Constructor_InitializesWithDefaultValues() + { + var vm = CreateViewModel(); + + Assert.NotNull(vm); + Assert.Equal(ContentType.Mod, vm.SelectedContentType); + Assert.Equal(GameType.ZeroHour, vm.SelectedGameType); + Assert.Empty(vm.ContentName); + Assert.Empty(vm.SourcePath); + Assert.Empty(vm.FileTree); + Assert.False(vm.IsEditing); + Assert.False(vm.CanAdd); + Assert.False(vm.ShowExecutableSelection); + Assert.Null(vm.SelectedExecutableItem); + Assert.Equal(0, vm.ExecutableCount); + Assert.Equal("Add Local Content", vm.DialogTitle); + Assert.Equal("Add to Library", vm.ActionButtonText); + Assert.Contains(ContentType.GameClient, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.ModdingTool, AddLocalContentViewModel.AllowedContentTypes); + Assert.Contains(ContentType.Executable, AddLocalContentViewModel.AllowedContentTypes); + } + + /// + /// Verifies that PreviewIdleText changes based on SelectedContentType. + /// + /// The content type under test. + /// The expected idle description text. + [Theory] + [InlineData(ContentType.Mod, "Import mod content (e.g. .big, .zip)")] + [InlineData(ContentType.GameClient, "Import GameClient")] + [InlineData(ContentType.Executable, "Import executable")] + [InlineData(ContentType.ModdingTool, "Import tool executable")] + [InlineData(ContentType.Patch, "Import patch")] + [InlineData(ContentType.Addon, "Import addon content")] + [InlineData(ContentType.Map, "Import map files")] + [InlineData(ContentType.MapPack, "Import map pack files")] + [InlineData(ContentType.Mission, "Import mission content")] + public void PreviewIdleText_ReturnsExpectedDescriptions(ContentType type, string expectedText) + { + var vm = CreateViewModel(); + vm.SelectedContentType = type; + + Assert.Equal(expectedText, vm.PreviewIdleText); + } + + /// + /// Verifies that ShowExecutableSelection is true when ExecutableCount > 0 for GameClient, ModdingTool, and Executable. + /// + /// The content type under test. + /// The number of detected executables. + /// The expected boolean indicating whether executable selection is shown. + [Theory] + [InlineData(ContentType.GameClient, 1, true)] + [InlineData(ContentType.GameClient, 2, true)] + [InlineData(ContentType.ModdingTool, 1, true)] + [InlineData(ContentType.ModdingTool, 2, true)] + [InlineData(ContentType.Executable, 1, true)] + [InlineData(ContentType.Executable, 2, true)] + [InlineData(ContentType.GameClient, 0, false)] + [InlineData(ContentType.ModdingTool, 0, false)] + [InlineData(ContentType.Executable, 0, false)] + [InlineData(ContentType.Mod, 1, false)] + [InlineData(ContentType.Mod, 2, false)] + [InlineData(ContentType.Patch, 1, false)] + [InlineData(ContentType.Map, 1, false)] + public void ShowExecutableSelection_EvaluatesCorrectly_BasedOnContentTypeAndExecutableCount( + ContentType contentType, + int executableCount, + bool expectedShow) + { + var vm = CreateViewModel(); + vm.SelectedContentType = contentType; + vm.ExecutableCount = executableCount; + + Assert.Equal(expectedShow, vm.ShowExecutableSelection); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for GameClient. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForGameClient_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "generals.exe"); + var dataPath = Path.Combine(tempDir, "data.ini"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "fake-data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("generals.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for ModdingTool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForModdingTool_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "FinalBIG.exe"); + var dataPath = Path.Combine(tempDir, "readme.txt"); + File.WriteAllText(exePath, "fake-exe-content"); + File.WriteAllText(dataPath, "read me"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("FinalBIG.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that importing a directory with an executable auto-selects the executable for Executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ImportContentAsync_WithSingleExecutable_ForExecutable_AutoSelectsExecutable() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "WorldBuilder.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(1, vm.ExecutableCount); + Assert.True(vm.ShowExecutableSelection); + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("WorldBuilder.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + } + + /// + /// Verifies that switching to an executable content type triggers auto-selection if an executable is in the tree. + /// + /// The executable content type to switch to. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task SelectedContentTypeChanged_ToExecutableType_AutoSelectsFirstExecutable(ContentType newType) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Launcher.exe"); + File.WriteAllText(exePath, "fake-exe-content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + + await vm.ImportContentAsync(tempDir); + + // When imported as Mod, no auto-selection happened + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.ShowExecutableSelection); + + // Switch to executable type + vm.SelectedContentType = newType; + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("Launcher.exe", vm.SelectedExecutableItem!.Name); + Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable); + Assert.True(vm.ShowExecutableSelection); + } + + /// + /// Verifies manual selection of an executable via SelectExecutableCommand. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_SwitchesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + var exe1Path = Path.Combine(tempDir, "Primary.exe"); + var exe2Path = Path.Combine(tempDir, "Secondary.exe"); + File.WriteAllText(exe1Path, "fake-exe-1"); + File.WriteAllText(exe2Path, "fake-exe-2"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal(2, vm.ExecutableCount); + Assert.NotNull(vm.SelectedExecutableItem); + + var initialSelected = vm.SelectedExecutableItem!; + var otherItem = FindInTree(vm.FileTree, f => f != initialSelected && f.IsExecutable); + Assert.NotNull(otherItem); + Assert.False(otherItem!.IsSelectedExecutable); + + // Select the other executable + vm.SelectExecutableCommand.Execute(otherItem); + + Assert.Equal(otherItem.Name, vm.SelectedExecutableItem.Name); + Assert.True(otherItem.IsSelectedExecutable); + Assert.False(initialSelected.IsSelectedExecutable); + } + + /// + /// Verifies that SelectExecutableCommand ignores non-executable files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SelectExecutableCommand_IgnoresNonExecutableItem() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Tool.exe"); + var txtPath = Path.Combine(tempDir, "Doc.txt"); + File.WriteAllText(exePath, "fake-exe"); + File.WriteAllText(txtPath, "text"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Executable; + + await vm.ImportContentAsync(tempDir); + + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + + var txtItem = FindInTree(vm.FileTree, f => f.Name == "Doc.txt"); + Assert.NotNull(txtItem); + Assert.False(txtItem!.IsExecutable); + + vm.SelectExecutableCommand.Execute(txtItem); + + // Should still be Tool.exe + Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name); + Assert.False(txtItem.IsSelectedExecutable); + } + + /// + /// Verifies that CanAdd validation requires an executable for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_RequiresExecutable_ForExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "config.ini"); + File.WriteAllText(txtPath, "config"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Tool"; + + await vm.ImportContentAsync(tempDir); + + // No executable found, so CanAdd should be false + Assert.Null(vm.SelectedExecutableItem); + Assert.False(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true for non-executable types without an executable. + /// + /// The non-executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.Mod)] + [InlineData(ContentType.Patch)] + [InlineData(ContentType.Addon)] + [InlineData(ContentType.Map)] + [InlineData(ContentType.MapPack)] + [InlineData(ContentType.Mission)] + public async Task Validation_CanAdd_DoesNotRequireExecutable_ForNonExecutableTypes(ContentType type) + { + var tempDir = CreateTempDirectory(); + var txtPath = Path.Combine(tempDir, "mod_data.big"); + File.WriteAllText(txtPath, "big archive data"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Mod"; + + await vm.ImportContentAsync(tempDir); + + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that CanAdd is true when an executable is present for GameClient, ModdingTool, and Executable. + /// + /// The executable content type under test. + /// A task representing the asynchronous test. + [Theory] + [InlineData(ContentType.GameClient)] + [InlineData(ContentType.ModdingTool)] + [InlineData(ContentType.Executable)] + public async Task Validation_CanAdd_IsTrue_WhenExecutableIsPresent(ContentType type) + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Main.exe"); + File.WriteAllText(exePath, "exe content"); + + var vm = CreateViewModel(); + vm.SelectedContentType = type; + vm.ContentName = "Test Item"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.True(vm.CanAdd); + } + + /// + /// Verifies that AddContentCommand forwards the relative entry point to ILocalContentService.CreateLocalContentManifestAsync. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_PassesEntryPoint_ToCreateLocalContentManifestAsync() + { + var tempDir = CreateTempDirectory(); + var exePath = Path.Combine(tempDir, "Game.exe"); + File.WriteAllText(exePath, "exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.gameclient.test"), + Name = "Test Game Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "Game.exe", + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Game Client"; + + // Import individual file so it lands at the root of staging + await vm.ImportContentAsync(exePath); + + Assert.True(vm.CanAdd); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Equal("Game.exe", capturedEntryPoint); + Assert.NotNull(vm.CreatedContentItem); + } + + /// + /// Verifies that AddContentCommand with nested executable passes correct relative path as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WithNestedExecutable_PassesRelativePathEntryPoint() + { + var tempDir = CreateTempDirectory(); + var subDir = Path.Combine(tempDir, "bin"); + Directory.CreateDirectory(subDir); + var exePath = Path.Combine(subDir, "tool.exe"); + File.WriteAllText(exePath, "tool exe"); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.moddingtool.tool"), + Name = "My Tool", + ContentType = ContentType.ModdingTool, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.ModdingTool; + vm.ContentName = "My Tool"; + + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + await vm.AddContentCommand.ExecuteAsync(null); + + var dirName = Path.GetFileName(tempDir); + Assert.Equal($"{dirName}/bin/tool.exe", capturedEntryPoint); + } + + /// + /// Verifies that LoadFromManifestAsync preserves the manifest EntryPoint when reloading for edit. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task LoadFromManifestAsync_PreservesManifestEntryPoint() + { + var manifestId = ManifestId.Create("1.0.local.gameclient.zh"); + + var manifest = new ContentManifest + { + Id = manifestId, + Name = "ZH Client", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + EntryPoint = "special.exe", + Files = + [ + new ManifestFile { RelativePath = "special.exe", IsExecutable = true }, + new ManifestFile { RelativePath = "bin/decoy.exe", IsExecutable = true }, + ], + }; + + _contentStorageServiceMock + .Setup(x => x.RetrieveContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, targetPath, _) => + { + Directory.CreateDirectory(targetPath); + File.WriteAllText(Path.Combine(targetPath, "special.exe"), "exe"); + var targetSub = Path.Combine(targetPath, "bin"); + Directory.CreateDirectory(targetSub); + File.WriteAllText(Path.Combine(targetSub, "decoy.exe"), "decoy"); + }) + .ReturnsAsync((ManifestId _, string targetPath, CancellationToken _) => OperationResult.CreateSuccess(targetPath)); + + string? capturedEntryPoint = null; + _localContentServiceMock + .Setup(x => x.UpdateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var item = new GenHub.Features.GameProfiles.ViewModels.ContentDisplayItem + { + Id = manifestId.Value, + ManifestId = manifestId, + DisplayName = "ZH Client", + ContentType = ContentType.GameClient, + GameType = GameType.ZeroHour, + InstallationType = GameInstallationType.Unknown, + Manifest = manifest, + }; + + var vm = CreateViewModel(); + await vm.LoadFromManifestAsync(item); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("special.exe", vm.SelectedExecutableItem.Name); + + await vm.AddContentCommand.ExecuteAsync(null); + Assert.Equal("special.exe", capturedEntryPoint); + } + + /// + /// Verifies that deleting an unrelated item preserves the previously selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + var readme = FindInTree(vm.FileTree, f => f.Name == "readme.txt"); + Assert.NotNull(readme); + await vm.DeleteItemCommand.ExecuteAsync(readme); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that deleting the currently selected executable falls back to auto-selecting the remaining executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeleteItemAsync_WhenSelectedExecutableDeleted_FallsBackToRemainingExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + await vm.DeleteItemCommand.ExecuteAsync(secondExe); + + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("first.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that switching content type away from executable and back preserves the selected entry point. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_SwitchAwayAndBack_PreservesSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first"); + File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe"); + Assert.NotNull(secondExe); + vm.SelectExecutableCommand.Execute(secondExe); + Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name); + + // Switch to Mod (non-executable type) + vm.SelectedContentType = ContentType.Mod; + Assert.Null(vm.SelectedExecutableItem); + + // Switch back to GameClient (executable type) + vm.SelectedContentType = ContentType.GameClient; + Assert.NotNull(vm.SelectedExecutableItem); + Assert.Equal("second.exe", vm.SelectedExecutableItem.Name); + } + + /// + /// Verifies that BuildDirectoryTree prioritizes directories containing executables over non-executable directories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task BuildDirectoryTree_PrioritizesDirectoriesWithExecutables() + { + var tempDir = CreateTempDirectory(); + + // Create 25 directories named folder01 to folder25 + for (var i = 1; i <= 25; i++) + { + var folder = Path.Combine(tempDir, $"folder{i:D2}"); + Directory.CreateDirectory(folder); + File.WriteAllText(Path.Combine(folder, "data.txt"), "content"); + } + + // Put an executable only in the 25th folder + var targetFolder = Path.Combine(tempDir, "folder25"); + File.WriteAllText(Path.Combine(targetFolder, "game.exe"), "executable"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + var folder25 = FindInTree(vm.FileTree, f => f.Name == "folder25"); + Assert.NotNull(folder25); + + var exe = FindInTree(folder25.Children, f => f.Name == "game.exe"); + Assert.NotNull(exe); + Assert.True(exe.IsExecutable); + } + + /// + /// Verifies that switching from an executable type to a non-executable type clears the selected executable. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ContentTypeChanged_FromExecutableToNonExecutable_ClearsSelectedExecutable() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "game.exe"), "game"); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.GameClient; + vm.ContentName = "Test Client"; + await vm.ImportContentAsync(tempDir); + + Assert.NotNull(vm.SelectedExecutableItem); + + vm.SelectedContentType = ContentType.Mod; + + Assert.Null(vm.SelectedExecutableItem); + } + + /// + /// Verifies that AddContentCommand with non-executable content type passes null as entryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task AddContentCommand_WhenNonExecutableType_PassesNullEntryPoint() + { + var tempDir = CreateTempDirectory(); + File.WriteAllText(Path.Combine(tempDir, "somefile.txt"), "text"); + + string? capturedEntryPoint = "INITIAL"; + _localContentServiceMock + .Setup(x => x.CreateLocalContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny())) + .Callback?, CancellationToken, string?>( + (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest + { + Id = ManifestId.Create("1.0.local.mod.test"), + Name = "My Mod", + ContentType = ContentType.Mod, + TargetGame = GameType.ZeroHour, + })); + + var vm = CreateViewModel(); + vm.SelectedContentType = ContentType.Mod; + vm.ContentName = "My Mod"; + await vm.ImportContentAsync(tempDir); + + await vm.AddContentCommand.ExecuteAsync(null); + + Assert.Null(capturedEntryPoint); + } + + private static FileTreeItem? FindInTree(IEnumerable items, Func predicate) + { + foreach (var item in items) + { + if (predicate(item)) return item; + var child = FindInTree(item.Children, predicate); + if (child != null) return child; + } + + return null; + } + + private string CreateTempDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "AddLocalContentVmTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + _tempDirectories.Add(path); + return path; + } + + private AddLocalContentViewModel CreateViewModel() + { + var vm = new AddLocalContentViewModel( + _localContentServiceMock.Object, + _contentStorageServiceMock.Object, + _normalizationServiceMock.Object, + _dialogServiceMock.Object, + NullLogger.Instance); + _viewModels.Add(vm); + return vm; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs index ec8f7c927..ac6b1e057 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs @@ -1,5 +1,6 @@ using System.Reactive.Linq; using GenHub.Common.ViewModels; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Interfaces.GameProfiles; @@ -14,6 +15,8 @@ using GenHub.Core.Interfaces.Tools; using GenHub.Core.Interfaces.UserData; using GenHub.Core.Interfaces.Workspace; +using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Common; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; @@ -160,6 +163,368 @@ public async Task InitializeAsync_MultipleCallsAreSafeAsync() Assert.True(true); } + /// + /// Tests that receiving updates periodic update timer settings without throwing. + /// + [Fact] + public void Receive_UpdateSettingsChangedMessage_UpdatesPeriodicTimer() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var mockVelopackUpdateManager = new Mock(); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act & Assert (should not throw when enabling/disabling or changing interval) + vm.Receive(new UpdateSettingsChangedMessage(false, true, 30)); + vm.Receive(new UpdateSettingsChangedMessage(false, false, 30)); + Assert.True(true); + } + + /// + /// Tests that when AutoCheckForUpdatesOnStartup is false, background update check is not triggered on initialize. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenAutoCheckForUpdatesOnStartupFalse_DoesNotCheckUpdatesOnStartupAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings + { + AutoCheckForUpdatesOnStartup = false, + AutoCheckForUpdatesPeriodically = false, + SubscribedBranch = "main", + }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var mockVelopackUpdateManager = new Mock(); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + await Task.Delay(100); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.Never); + mockVelopackUpdateManager.Verify(x => x.CheckForUpdatesAsync(It.IsAny()), Times.Never); + } + + /// + /// Tests that background update check queries artifact updates when subscribed to a PR. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSubscribedToPr_ChecksArtifactUpdatesAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 265 }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var updateCheckedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(() => + { + updateCheckedTcs.TrySetResult(true); + return Task.FromResult(null); + }); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + + // Await deterministic completion of background check + await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Tests that background update check queries artifact updates when subscribed to a branch. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSubscribedToBranch_ChecksArtifactUpdatesAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var updateCheckedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(() => + { + updateCheckedTcs.TrySetResult(true); + return Task.FromResult(null); + }); + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + await vm.InitializeAsync(); + + // Await deterministic completion of background check + await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Tests that background update check shows an update notification with update action and triggers progress notification on click. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenArtifactUpdateAvailable_ShowsNotificationWithUpdateActionAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var shownNotifications = new List(); + var notificationShownTcs = new TaskCompletionSource(); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-main", + GitHash: "abcdef1", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var installStartedTcs = new TaskCompletionSource(); + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + mockVelopackUpdateManager.Setup(x => x.InstallArtifactAsync( + artifactInfo, + It.IsAny>(), + It.IsAny())) + .Callback(() => installStartedTcs.TrySetResult(true)) + .Returns(Task.CompletedTask); + + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + shownNotifications.Add(msg); + if (msg.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act + await vm.InitializeAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.Single(updateNotification.Actions); + Assert.Equal(AppUpdateConstants.UpdateAction, updateNotification.Actions[0].Text); + + // Act - simulate clicking the update action button + updateNotification.Actions[0].Callback?.Invoke(); + + // Await background install execution deterministically + await installStartedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert that progress notification was displayed + mockVelopackUpdateManager.Verify(x => x.InstallArtifactAsync(artifactInfo, It.IsAny>(), It.IsAny()), Times.Once); + Assert.Contains(shownNotifications, n => n.Title == AppUpdateConstants.UpdatingAppNotificationTitle); + } + + /// + /// Tests that background update check does not create duplicate notifications when the same update is detected repeatedly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task InitializeAsync_WhenSameArtifactUpdateCheckedRepeatedly_DeduplicatesNotificationAsync() + { + // Arrange + var (settingsVm, userSettingsMock) = CreateSettingsVm(); + userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" }); + var toolsVm = CreateToolsVm(); + var configProvider = CreateConfigProviderMock(); + var shownNotifications = new List(); + var notificationShownTcs = new TaskCompletionSource(); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-main", + GitHash: "abcdef1", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopackUpdateManager = new Mock(); + mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockLogger = new Mock>(); + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + shownNotifications.Add(msg); + if (msg.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockNotificationManager = new Mock( + mockNotificationService.Object, + Mock.Of>(), + Mock.Of>()); + var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object); + + using var vm = new MainViewModel( + gameProfilesViewModel: CreateGameProfileLauncherViewModel(), + downloadsViewModel: CreateDownloadsViewModel(configProvider), + toolsViewModel: toolsVm, + settingsViewModel: settingsVm, + notificationManager: mockNotificationManager.Object, + configurationProvider: configProvider, + userSettingsService: userSettingsMock.Object, + velopackUpdateManager: mockVelopackUpdateManager.Object, + notificationService: mockNotificationService.Object, + dialogService: new Mock().Object, + notificationFeedViewModel: notificationFeedVm, + infoViewModel: CreateInfoViewModel(), + logger: mockLogger.Object); + + // Act + await vm.InitializeAsync(); + await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Re-trigger update check via second initialize call and message receipt + vm.Receive(new UpdateSettingsChangedMessage(true, true, 5)); + await vm.InitializeAsync(); + await Task.Delay(200); + + // Assert that branch update notification was shown exactly once + var branchUpdateNotifications = shownNotifications + .Where(n => n.Title == AppUpdateConstants.BranchUpdateAvailableNotificationTitle) + .ToList(); + Assert.Single(branchUpdateNotifications); + } + /// /// Tests that CurrentTabViewModel returns the correct ViewModel based on SelectedTab. /// @@ -365,6 +730,7 @@ private static Mock CreateNotificationServiceMock() mock.Setup(x => x.NotificationHistory).Returns(Observable.Empty()); mock.Setup(x => x.DismissRequests).Returns(Observable.Empty()); mock.Setup(x => x.DismissAllRequests).Returns(Observable.Empty()); + mock.Setup(x => x.UpdateRequests).Returns(Observable.Empty<(Guid Id, string? Title, string Message)>()); return mock; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs index caadee096..5a2cd3cbb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/SettingsViewModelTests.cs @@ -171,6 +171,86 @@ public async Task ResetToDefaultsCommand_ResetsAllPropertiesAsync() Assert.Equal(3, viewModel.MaxConcurrentDownloads); Assert.False(viewModel.EnableDetailedLogging); Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, viewModel.DefaultWorkspaceStrategy); + Assert.True(viewModel.AutoCheckForUpdatesPeriodically); + Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, viewModel.PeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Verifies that periodic update settings are correctly loaded from UserSettings. + /// + [Fact] + public void Constructor_LoadsPeriodicUpdateSettingsFromUserSettingsService() + { + // Arrange + var customSettings = new UserSettings + { + AutoCheckForUpdatesPeriodically = false, + PeriodicUpdateCheckIntervalMinutes = 15, + }; + + _mockConfigService.Setup(x => x.Get()).Returns(customSettings); + + // Act + var viewModel = new SettingsViewModel( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object); + + // Assert + Assert.False(viewModel.AutoCheckForUpdatesPeriodically); + Assert.Equal(15, viewModel.PeriodicUpdateCheckIntervalMinutes); + } + + /// + /// Verifies that SaveSettingsCommand persists periodic update settings. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task SaveSettingsCommand_UpdatesPeriodicUpdateSettingsAsync() + { + // Arrange + var viewModel = new SettingsViewModel( + _mockConfigService.Object, + _mockLogger.Object, + _mockCasService.Object, + _mockProfileManager.Object, + _mockWorkspaceManager.Object, + _mockManifestPool.Object, + _mockUpdateManager.Object, + _mockNotificationService.Object, + _mockConfigurationProvider.Object, + _mockInstallationService.Object, + _mockStorageLocationService.Object, + _mockUserDataTracker.Object) + { + AutoCheckForUpdatesPeriodically = false, + PeriodicUpdateCheckIntervalMinutes = 45, + }; + + UserSettings? capturedSettings = null; + _mockConfigService.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => + { + capturedSettings = new UserSettings(); + action(capturedSettings); + }); + + // Act + await Task.Run(() => viewModel.SaveSettingsCommand.Execute(null)); + + // Assert + Assert.NotNull(capturedSettings); + Assert.False(capturedSettings.AutoCheckForUpdatesPeriodically); + Assert.Equal(45, capturedSettings.PeriodicUpdateCheckIntervalMinutes); } /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs new file mode 100644 index 000000000..84ab32157 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/AppUpdateVersionHelperTests.cs @@ -0,0 +1,109 @@ +using GenHub.Core.Helpers; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public class AppUpdateVersionHelperTests +{ + /// + /// Tests that ExtractRunNumber extracts expected run numbers. + /// + /// The version string to extract the run number from. + /// The expected run number. + [Theory] + [InlineData("0.0.1282-pr265", 1282)] + [InlineData("0.0.1287-pr265", 1287)] + [InlineData("0.0.1287-main", 1287)] + [InlineData("0.0.1287-development", 1287)] + [InlineData("0.0.1300-fix-ci.9", 1300)] + [InlineData("0.0.1287", 1287)] + [InlineData("0.0.0-ci.500", 500)] + [InlineData("1.0.42", 0)] + [InlineData("1.2.5", 0)] + [InlineData("", 0)] + [InlineData(" ", 0)] + [InlineData(null, 0)] + [InlineData("abc", 0)] + public void ExtractRunNumber_WithVariousFormats_ShouldReturnExpectedNumber(string? version, int expectedRun) + { + var result = AppUpdateVersionHelper.ExtractRunNumber(version); + Assert.Equal(expectedRun, result); + } + + /// + /// Tests that IsArtifactVersionNewer returns true when new run is greater. + /// + [Fact] + public void IsArtifactVersionNewer_WhenNewerRun_ShouldReturnTrue() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1287-pr265", "0.0.1282-pr265"); + Assert.True(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when same run. + /// + [Fact] + public void IsArtifactVersionNewer_WhenSameRun_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", "0.0.1282-pr265"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer returns false when older run. + /// + [Fact] + public void IsArtifactVersionNewer_WhenOlderRun_ShouldReturnFalse() + { + var result = AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1280-pr265", "0.0.1282-pr265"); + Assert.False(result); + } + + /// + /// Tests that IsArtifactVersionNewer works for branch versions. + /// + [Fact] + public void IsArtifactVersionNewer_BranchVersions_ShouldCompareCorrectly() + { + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1287-main", "0.0.1282-main")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-main", "0.0.1282-main")); + } + + /// + /// Tests that IsArtifactVersionNewer handles null or empty inputs. + /// + [Fact] + public void IsArtifactVersionNewer_WithNullOrEmpty_ShouldHandleGracefully() + { + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer(null, "0.0.1282-pr265")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer(string.Empty, "0.0.1282-pr265")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", null)); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", string.Empty)); + } + + /// + /// Tests that fallback versions like 0.0.0 are not treated as newer than installed builds. + /// + [Fact] + public void IsArtifactVersionNewer_FallbackZeroVersusValidRun_ShouldReturnFalse() + { + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.0", "0.0.1282-pr265")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("0.0.1282-pr265", "0.0.0")); + } + + /// + /// Tests that standard version numbers compare correctly when no run number is present. + /// + [Fact] + public void IsArtifactVersionNewer_SemanticVersion_ShouldCompareCorrectly() + { + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("1.2.5", "1.1.9")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.1.9", "1.2.5")); + Assert.True(AppUpdateVersionHelper.IsArtifactVersionNewer("1.2.0", "1.1.0")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.1.0", "1.2.0")); + Assert.False(AppUpdateVersionHelper.IsArtifactVersionNewer("1.0.0", "1.0.0")); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs new file mode 100644 index 000000000..d9d9fa997 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs @@ -0,0 +1,234 @@ +using System; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class CommandLineParserTests +{ + /// + /// Verifies that ExtractProfileId correctly extracts profile id from spaced argument. + /// + [Fact] + public void ExtractProfileId_WithSpacedArgument_ReturnsProfileId() + { + var args = new[] { "--other", "value", "--launch-profile", "test-profile-123" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Equal("test-profile-123", result); + } + + /// + /// Verifies that ExtractProfileId correctly extracts profile id from inline argument. + /// + [Fact] + public void ExtractProfileId_WithInlineArgument_ReturnsProfileId() + { + var args = new[] { "--launch-profile=test-profile-456" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Equal("test-profile-456", result); + } + + /// + /// Verifies that ExtractProfileId trims surrounding quotes. + /// + [Fact] + public void ExtractProfileId_WithQuotedValues_ReturnsTrimmedProfileId() + { + var argsSpaced = new[] { "--launch-profile", "\"quoted-profile\"" }; + var argsInline = new[] { "--launch-profile=\"quoted-profile\"" }; + + Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsSpaced)); + Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsInline)); + } + + /// + /// Verifies that ExtractProfileId returns null when launch profile argument is absent. + /// + [Fact] + public void ExtractProfileId_WhenMissing_ReturnsNull() + { + var args = new[] { "--verbose", "--other" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractProfileId returns null when spaced argument has no subsequent value. + /// + [Fact] + public void ExtractProfileId_WhenFlagAtEndWithoutValue_ReturnsNull() + { + var args = new[] { "--launch-profile" }; + + var result = CommandLineParser.ExtractProfileId(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl parses direct catalog URLs. + /// + [Fact] + public void ExtractSubscriptionUrl_WithDirectUrl_ReturnsDecodedUrl() + { + var args = new[] { "genhub://subscribe?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl correctly decodes URL encoded parameters. + /// + [Fact] + public void ExtractSubscriptionUrl_WithUrlEncodedParameter_ReturnsDecodedUrl() + { + var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%3Fversion%3D1" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json?version=1", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl trims quotes around the url value. + /// + [Fact] + public void ExtractSubscriptionUrl_WithQuotedArgument_ReturnsTrimmedUrl() + { + var argsClean = new[] { "genhub://subscribe?url=\"https://example.com/catalog.json\"" }; + + Assert.Equal("https://example.com/catalog.json", CommandLineParser.ExtractSubscriptionUrl(argsClean)); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when no subscribe URI is present. + /// + [Fact] + public void ExtractSubscriptionUrl_WhenNotPresent_ReturnsNull() + { + var args = new[] { "--launch-profile", "test" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl is case insensitive with protocol prefix and query parameter. + /// + [Fact] + public void ExtractSubscriptionUrl_CaseInsensitivePrefix_ReturnsUrl() + { + var args = new[] { "GENHUB://SUBSCRIBE?URL=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when subscribe URI lacks the url query parameter. + /// + [Fact] + public void ExtractSubscriptionUrl_WithoutUrlParameter_ReturnsNull() + { + var args = new[] { "genhub://subscribe" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null when the url query parameter is empty. + /// + [Fact] + public void ExtractSubscriptionUrl_WithEmptyUrlParameter_ReturnsNull() + { + var args = new[] { "genhub://subscribe?url=" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } + + /// + /// Verifies that ExtractSubscriptionUrl extracts the URL even when preceded by other arguments. + /// + [Fact] + public void ExtractSubscriptionUrl_WhenNotFirstArgument_ReturnsUrl() + { + var args = new[] { "--verbose", "--launch-profile", "test-profile", "genhub://subscribe?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns the first matching subscription URL when multiple are present. + /// + [Fact] + public void ExtractSubscriptionUrl_MultipleUrls_ReturnsFirstMatch() + { + var args = new[] + { + "genhub://subscribe?url=https://example.com/first.json", + "genhub://subscribe?url=https://example.com/second.json", + }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/first.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null for non-HTTP and non-HTTPS URI schemes. + /// + [Fact] + public void ExtractSubscriptionUrl_NonHttpOrHttpsScheme_ReturnsNull() + { + var fileSchemeArgs = new[] { "genhub://subscribe?url=file:///C:/malicious.exe" }; + var jsSchemeArgs = new[] { "genhub://subscribe?url=javascript:alert(1)" }; + + Assert.Null(CommandLineParser.ExtractSubscriptionUrl(fileSchemeArgs)); + Assert.Null(CommandLineParser.ExtractSubscriptionUrl(jsSchemeArgs)); + } + + /// + /// Verifies that ExtractSubscriptionUrl strips newlines and control characters from the URL. + /// + [Fact] + public void ExtractSubscriptionUrl_WithNewlinesAndControlChars_ReturnsSanitizedUrl() + { + var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%0D%0A" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Equal("https://example.com/catalog.json", result); + } + + /// + /// Verifies that ExtractSubscriptionUrl returns null for non-command subscribe-prefixed URIs. + /// + [Fact] + public void ExtractSubscriptionUrl_WithNonCommandSubscribePrefixedUri_ReturnsNull() + { + var args = new[] { "genhub://subscribe-anything?url=https://example.com/catalog.json" }; + + var result = CommandLineParser.ExtractSubscriptionUrl(args); + + Assert.Null(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs index 913684ff1..6de3d7e9a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -81,7 +80,6 @@ public ContentReconciliationServiceTests() /// /// A representing the asynchronous unit test. [Fact] - [SuppressMessage("DeepSource", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")] public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToPool_AndUpdateProfilesAsync() { // Arrange @@ -128,7 +126,7 @@ public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToP _profileManagerMock.Verify( x => x.UpdateProfileAsync( "profile-1", - It.Is(r => r.GameClient != null && r.GameClient.Id == newId), + It.Is(r => MatchesGameClientId(r, newId)), It.IsAny()), Times.Once, "Should update profile with new manifest ID"); @@ -282,4 +280,7 @@ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailureAsyn result.FirstError.Should().Be( GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage); } + + private static bool MatchesGameClientId(UpdateProfileRequest request, string expectedId) => + request.GameClient?.Id == expectedId; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs new file mode 100644 index 000000000..529be0d13 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; +using GenHub.Windows.Features.Shortcuts; +using Microsoft.Win32; +using Xunit; +using Xunit.Abstractions; + +namespace GenHub.Tests.Windows.Features.Shortcuts; + +/// +/// Unit tests for . +/// +/// Output helper for surfacing test diagnostic messages. +[Collection(WindowsRegistryCollection.Name)] +[SupportedOSPlatform("windows")] +public sealed class UriSchemeRegistrarTests(ITestOutputHelper testOutputHelper) : IDisposable +{ + private const string TargetKeyPath = @"Software\Classes\genhub"; + private readonly RegistryKeySnapshot? _snapshot = CaptureInitialSnapshot(); + private readonly bool _existedPrior = KeyExists(); + + /// + /// Verifies that Register creates or updates the genhub registry keys in HKCU. + /// + [Fact] + public void Register_CreatesOrUpdatesGenhubRegistryKey() + { + // Act + UriSchemeRegistrar.Register(); + + // Assert + using var key = Registry.CurrentUser.OpenSubKey(TargetKeyPath); + Assert.NotNull(key); + + var protocolValue = key.GetValue(string.Empty) as string; + Assert.Equal("URL:genhub protocol", protocolValue); + + var urlProtocolFlag = key.GetValue("URL Protocol"); + Assert.NotNull(urlProtocolFlag); + + using var commandKey = Registry.CurrentUser.OpenSubKey($@"{TargetKeyPath}\shell\open\command"); + Assert.NotNull(commandKey); + + var command = commandKey.GetValue(string.Empty) as string; + Assert.NotNull(command); + Assert.Contains("%1", command); + Assert.Contains(Environment.ProcessPath ?? string.Empty, command, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that Register can be invoked repeatedly without failure or unexpected mutations. + /// + [Fact] + public void Register_IsIdempotent() + { + // Act - Call twice in succession to ensure no exceptions or unintended side effects occur + UriSchemeRegistrar.Register(); + var ex = Record.Exception(() => UriSchemeRegistrar.Register()); + + // Assert + Assert.Null(ex); + } + + /// + public void Dispose() + { + try + { + if (_existedPrior && _snapshot != null) + { + using var rootKey = Registry.CurrentUser.CreateSubKey(TargetKeyPath, writable: true); + if (rootKey != null) + { + RestoreSnapshot(rootKey, _snapshot); + } + } + else + { + Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false); + } + } + catch (Exception ex) + { + testOutputHelper.WriteLine($"Failed to restore registry snapshot during test teardown: {ex.Message}"); + } + } + + private static bool KeyExists() + { + using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); + return rootKey != null; + } + + private static RegistryKeySnapshot? CaptureInitialSnapshot() + { + using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); + return rootKey != null ? CaptureSnapshot(rootKey) : null; + } + + private static RegistryKeySnapshot CaptureSnapshot(RegistryKey key) + { + var snapshot = new RegistryKeySnapshot + { + Name = Path.GetFileName(key.Name), + }; + + foreach (var valueName in key.GetValueNames()) + { + var value = key.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames); + var kind = key.GetValueKind(valueName); + snapshot.Values[valueName] = (value, kind); + } + + foreach (var subKeyName in key.GetSubKeyNames()) + { + using var subKey = key.OpenSubKey(subKeyName, writable: false); + if (subKey != null) + { + snapshot.SubKeys.Add(CaptureSnapshot(subKey)); + } + } + + return snapshot; + } + + private static void RestoreSnapshot(RegistryKey targetKey, RegistryKeySnapshot snapshot) + { + // Delete values not present in snapshot + foreach (var valueName in targetKey.GetValueNames()) + { + if (!snapshot.Values.ContainsKey(valueName)) + { + targetKey.DeleteValue(valueName, throwOnMissingValue: false); + } + } + + // Restore values + foreach (var (valueName, (value, kind)) in snapshot.Values) + { + if (value != null) + { + targetKey.SetValue(valueName, value, kind); + } + } + + // Delete subkeys not present in snapshot + var snapshotSubKeyNames = new HashSet(snapshot.SubKeys.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); + foreach (var subKeyName in targetKey.GetSubKeyNames()) + { + if (!snapshotSubKeyNames.Contains(subKeyName)) + { + targetKey.DeleteSubKeyTree(subKeyName, throwOnMissingSubKey: false); + } + } + + // Restore subkeys recursively + foreach (var subKeySnapshot in snapshot.SubKeys) + { + using var subKey = targetKey.CreateSubKey(subKeySnapshot.Name, writable: true); + if (subKey != null) + { + RestoreSnapshot(subKey, subKeySnapshot); + } + } + } + + private sealed class RegistryKeySnapshot + { + public string Name { get; set; } = string.Empty; + + public Dictionary Values { get; } = []; + + public List SubKeys { get; } = []; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs new file mode 100644 index 000000000..23847849f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs @@ -0,0 +1,15 @@ +using Xunit; + +namespace GenHub.Tests.Windows.Features.Shortcuts; + +/// +/// Prevents registry tests from overlapping and racing. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class WindowsRegistryCollection +{ + /// + /// The xUnit collection name. + /// + public const string Name = "Windows registry"; +} diff --git a/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs new file mode 100644 index 000000000..1917cf585 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace GenHub.Windows.Features.Shortcuts; + +/// +/// Registers the genhub:// URI scheme with Windows so OS/browser links open GenHub. +/// +/// +/// +/// Windows resolves custom protocols through HKCU\Software\Classes\<scheme>. Without +/// that key the shell shows an "app not installed" dialog when a genhub:// link is clicked. +/// The app already parses genhub://subscribe?url=... from its own command line +/// (GenHub.Core.Helpers.CommandLineParser.ExtractSubscriptionUrl); this registrar wires the +/// OS shell to that path. +/// +/// +/// Writes to HKCU (per-user), so no elevation is required. The registration is idempotent +/// and self-repairs: it rewrites the command only when the executable path has changed, which is +/// what happens every time a debug rebuild or Velopack update lands at a new path. +/// +/// +public static class UriSchemeRegistrar +{ + private const string SchemeName = CommandLineConstants.SchemeName; + private const string ClassesSubKey = @"Software\Classes\" + SchemeName; + + /// + /// Registers the genhub:// scheme for the current user, pointing at the running + /// executable. Safe to call on every launch. + /// + /// Optional logger for diagnostics. + public static void Register(ILogger? logger = null) + { + var executablePath = Environment.ProcessPath; + if (string.IsNullOrEmpty(executablePath) || !File.Exists(executablePath)) + { + logger?.LogWarning("Could not register genhub:// scheme: executable path unavailable."); + return; + } + + try + { + var desiredCommand = $"\"{executablePath}\" \"%1\""; + var desiredProtocol = $"URL:{SchemeName} protocol"; + var desiredIcon = $"{executablePath},0"; + + // Check if already registered and up-to-date before performing any writes + using (var existingClassesKey = Registry.CurrentUser.OpenSubKey(ClassesSubKey, writable: false)) + { + if (existingClassesKey != null) + { + var existingProtocol = existingClassesKey.GetValue(string.Empty) as string; + var existingUrlProtocol = existingClassesKey.GetValue("URL Protocol"); + + using var existingCommandKey = existingClassesKey.OpenSubKey(@"shell\open\command", writable: false); + var existingCommand = existingCommandKey?.GetValue(string.Empty) as string; + + if (string.Equals(existingProtocol, desiredProtocol, StringComparison.OrdinalIgnoreCase) && + existingUrlProtocol != null && + string.Equals(existingCommand, desiredCommand, StringComparison.OrdinalIgnoreCase)) + { + logger?.LogDebug("genhub:// scheme is already registered and up-to-date."); + return; + } + } + } + + using var classesKey = Registry.CurrentUser.CreateSubKey(ClassesSubKey, writable: true); + + // URL Protocol flag tells the shell this is a URI handler, not a normal file type. + classesKey.SetValue(string.Empty, desiredProtocol); + classesKey.SetValue("URL Protocol", string.Empty); + + using var iconKey = classesKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(string.Empty, desiredIcon); + + using var commandKey = classesKey.CreateSubKey(@"shell\open\command"); + commandKey.SetValue(string.Empty, desiredCommand); + + logger?.LogInformation("Registered genhub:// scheme -> {ExecutablePath}", executablePath); + } + catch (Exception ex) + { + // Registration failure must never block app startup; the in-app subscribe paths still + // work via direct command-line invocation. + logger?.LogWarning(ex, "Failed to register genhub:// scheme."); + } + } +} diff --git a/GenHub/GenHub.Windows/Program.cs b/GenHub/GenHub.Windows/Program.cs index 031e8108f..996834a0d 100644 --- a/GenHub/GenHub.Windows/Program.cs +++ b/GenHub/GenHub.Windows/Program.cs @@ -52,7 +52,7 @@ public static void Main(string[] args) // Extract profile ID from args if present (for IPC forwarding) var profileId = CommandLineParser.ExtractProfileId(args); - // Extract subscription URL from args if present (for IPC forwarding) + // Extract genhub://subscribe?url=... target (catalog JSON today; definition URL later) var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args); // Check for multi-instance mode (useful for debugging with multiple instances) @@ -74,7 +74,7 @@ public static void Main(string[] args) SingleInstanceManager.SendCommandToPrimaryInstance($"{IpcCommands.LaunchProfilePrefix}{profileId}"); } - // Forward subscribe command to primary instance if we have a subscription URL + // Forward subscribe so the running UI can show the confirmation dialog if (!string.IsNullOrEmpty(subscriptionUrl)) { bootstrapLogger.LogInformation("Forwarding subscribe command to primary instance: {Url}", subscriptionUrl); @@ -94,6 +94,10 @@ public static void Main(string[] args) bootstrapLogger.LogInformation("Multi-instance mode enabled - skipping single-instance check"); } + // Register the genhub:// URI scheme with Windows so clicked links open this executable. + // Registered for primary instance only; idempotent and per-user (HKCU). + Features.Shortcuts.UriSchemeRegistrar.Register(bootstrapLogger); + try { bootstrapLogger.LogInformation("Starting GenHub Windows application"); diff --git a/GenHub/GenHub/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 3017451f0..a2f92fc64 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -11,6 +11,8 @@ using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Models.Enums; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -65,8 +67,8 @@ public override void OnFrameworkInitializationCompleted() // Subscribe to IPC commands from secondary instances (Windows only) SubscribeToSingleInstanceCommands(mainWindow); - // Handle launch profile from startup args (first launch with shortcut) - SafeFireAndForget(HandleLaunchProfileArgsAsync(desktop.Args, mainWindow), "HandleLaunchProfileArgsAsync"); + // Handle startup arguments sequentially (launch profile, then subscription if present) + SafeFireAndForget(HandleStartupArgsAsync(desktop.Args, mainWindow), nameof(HandleStartupArgsAsync)); } base.OnFrameworkInitializationCompleted(); @@ -168,6 +170,17 @@ private async void OnShutdownRequested(object? sender, ShutdownRequestedEventArg } } + private async Task HandleStartupArgsAsync(string[]? args, MainWindow mainWindow) + { + if (args == null || args.Length == 0) + { + return; + } + + await HandleLaunchProfileArgsAsync(args, mainWindow); + await HandleSubscriptionArgsAsync(args, mainWindow); + } + private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainWindow) { if (args == null || args.Length == 0) @@ -187,6 +200,25 @@ private async Task HandleLaunchProfileArgsAsync(string[]? args, MainWindow mainW await LaunchProfileByIdAsync(profileId, mainWindow); } + private async Task HandleSubscriptionArgsAsync(string[]? args, MainWindow mainWindow) + { + if (args == null || args.Length == 0) + { + return; + } + + var subscriptionUrl = CommandLineParser.ExtractSubscriptionUrl(args); + if (string.IsNullOrWhiteSpace(subscriptionUrl)) + { + return; + } + + var logger = _serviceProvider.GetService>(); + logger?.LogInformation("Startup subscription detected for URL: {Url}", subscriptionUrl); + + await HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow); + } + private void SubscribeToSingleInstanceCommands(MainWindow mainWindow) { // Get the SingleInstanceManager from AppLocator (set by Windows Program.cs) @@ -197,10 +229,7 @@ private void SubscribeToSingleInstanceCommands(MainWindow mainWindow) } singleInstanceManager.CommandReceived += (_, command) => - { - // Dispatch to UI thread since the event comes from a background pipe listener Dispatcher.UIThread.Post(() => HandleSingleInstanceCommand(command, mainWindow)); - }; var logger = _serviceProvider.GetService>(); logger?.LogDebug("Subscribed to single instance IPC commands"); @@ -216,7 +245,15 @@ private void HandleSingleInstanceCommand(string command, MainWindow mainWindow) logger?.LogInformation("Received IPC launch command for profile: {ProfileId}", profileId); // Launch the profile - SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), "LaunchProfileByIdAsync"); + SafeFireAndForget(LaunchProfileByIdAsync(profileId, mainWindow), nameof(LaunchProfileByIdAsync)); + } + else if (command.StartsWith(IpcCommands.SubscribePrefix, StringComparison.OrdinalIgnoreCase)) + { + var subscriptionUrl = command[IpcCommands.SubscribePrefix.Length..]; + logger?.LogInformation("Received IPC subscribe command for URL: {Url}", subscriptionUrl); + + // Handle the subscription URL + SafeFireAndForget(HandleSubscriptionUrlAsync(subscriptionUrl, mainWindow), nameof(HandleSubscriptionUrlAsync)); } else { @@ -269,4 +306,48 @@ private async Task LaunchProfileByIdAsync(string profileId, MainWindow mainWindo logger?.LogError(ex, "Exception while launching profile {ProfileId}", profileId); } } + + private async Task HandleSubscriptionUrlAsync(string subscriptionUrl, MainWindow mainWindow) + { + var logger = _serviceProvider.GetService>(); + + try + { + var sanitizedUrl = subscriptionUrl.Replace("\r", string.Empty).Replace("\n", string.Empty).Trim('"', '\'', ' ', '\t'); + if (!Uri.TryCreate(sanitizedUrl, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + logger?.LogWarning("Invalid or unsafe subscription URL: {Url}", subscriptionUrl); + return; + } + + logger?.LogInformation("Handling subscription URL: {Url}", uri.AbsoluteUri); + + var dialogService = _serviceProvider.GetService(); + if (dialogService != null) + { + var confirmed = await dialogService.ShowConfirmationAsync( + "Subscribe to Catalog", + $"Do you want to subscribe to content from:\n{uri.AbsoluteUri}", + "Subscribe", + "Cancel"); + + if (confirmed) + { + if (mainWindow?.DataContext is MainViewModel mainViewModel) + { + mainViewModel.SelectTab(NavigationTab.Downloads); + } + + logger?.LogInformation("User confirmed subscription to: {Url}", uri.AbsoluteUri); + var notificationService = _serviceProvider.GetService(); + notificationService?.ShowSuccess("Subscribed", $"Successfully subscribed to: {uri.AbsoluteUri}"); + } + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Exception while handling subscription URL {Url}", subscriptionUrl); + } + } } diff --git a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs index 948cc7975..db8eb5745 100644 --- a/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs +++ b/GenHub/GenHub/Common/Services/ConfigurationProviderService.cs @@ -144,6 +144,23 @@ public bool GetAutoCheckForUpdatesOnStartup() return !settings.IsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesOnStartup)) || settings.AutoCheckForUpdatesOnStartup; // App default } + /// + public bool GetAutoCheckForUpdatesPeriodically() + { + var settings = _userSettings.Get(); + return !settings.IsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically)) || settings.AutoCheckForUpdatesPeriodically; // App default + } + + /// + public int GetPeriodicUpdateCheckIntervalMinutes() + { + var settings = _userSettings.Get(); + var value = settings.IsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes)) && settings.PeriodicUpdateCheckIntervalMinutes > 0 + ? settings.PeriodicUpdateCheckIntervalMinutes + : AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes; + return Math.Clamp(value, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + } + /// public bool GetEnableDetailedLogging() { @@ -215,6 +232,8 @@ public UserSettings GetEffectiveSettings() MaxConcurrentDownloads = GetMaxConcurrentDownloads(), AllowBackgroundDownloads = GetAllowBackgroundDownloads(), AutoCheckForUpdatesOnStartup = GetAutoCheckForUpdatesOnStartup(), + AutoCheckForUpdatesPeriodically = GetAutoCheckForUpdatesPeriodically(), + PeriodicUpdateCheckIntervalMinutes = GetPeriodicUpdateCheckIntervalMinutes(), LastUpdateCheckTimestamp = _userSettings.Get().LastUpdateCheckTimestamp, EnableDetailedLogging = GetEnableDetailedLogging(), DefaultWorkspaceStrategy = GetDefaultWorkspaceStrategy(), @@ -259,7 +278,11 @@ public List GetGitHubDiscoveryRepositories() settings.GitHubDiscoveryRepositories != null && settings.GitHubDiscoveryRepositories.Count > 0) return settings.GitHubDiscoveryRepositories; - return ["TheSuperHackers/GeneralsGameCode"]; + return + [ + $"{SuperHackersConstants.GeneralsGameCodeOwner}/{SuperHackersConstants.GeneralsGameCodeRepo}", + $"{SuperHackersConstants.GeneralsGamePatch2Owner}/{SuperHackersConstants.GeneralsGamePatch2Repo}", + ]; } /// diff --git a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs index b433c0f22..751255560 100644 --- a/GenHub/GenHub/Common/ViewModels/MainViewModel.cs +++ b/GenHub/GenHub/Common/ViewModels/MainViewModel.cs @@ -13,13 +13,16 @@ using CommunityToolkit.Mvvm.Messaging; using GenHub.Common.ViewModels.Dialogs; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Notifications; using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; using GenHub.Core.Models.Dialogs; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Notifications; using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.AppUpdate.ViewModels; using GenHub.Features.Downloads.ViewModels; using GenHub.Features.GameProfiles.ViewModels; using GenHub.Features.Info.ViewModels; @@ -27,6 +30,7 @@ using GenHub.Features.Settings.ViewModels; using GenHub.Features.Tools.ViewModels; using Microsoft.Extensions.Logging; +using Velopack; namespace GenHub.Common.ViewModels; @@ -59,9 +63,11 @@ public partial class MainViewModel( IDialogService dialogService, NotificationFeedViewModel notificationFeedViewModel, InfoViewModel infoViewModel, - ILogger logger) : ObservableObject, IDisposable, IRecipient + ILogger logger) : ObservableObject, IDisposable, IRecipient, IRecipient { private readonly CancellationTokenSource _initializationCts = new(); + private Timer? _periodicUpdateTimer; + private string? _lastNotifiedUpdateIdentity; /// /// Initializes a new instance of the class for design-time support. @@ -161,6 +167,12 @@ public void Receive(NavigationMessage message) Dispatcher.UIThread.Post(() => SelectTab(message.Tab)); } + /// + public void Receive(UpdateSettingsChangedMessage message) + { + RestartPeriodicUpdateTimer(message.AutoCheckForUpdatesPeriodically, message.PeriodicUpdateCheckIntervalMinutes); + } + /// /// Selects the specified navigation tab. /// @@ -184,8 +196,15 @@ public async Task InitializeAsync() await InfoViewModel.InitializeAsync(); logger?.LogInformation("MainViewModel initialized"); - // Start background check with cancellation support - _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + var settings = userSettingsService.Get(); + if (settings.AutoCheckForUpdatesOnStartup) + { + // Start background check with cancellation support + _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + } + + // Initialize periodic update timer + RestartPeriodicUpdateTimer(settings.AutoCheckForUpdatesPeriodically, settings.PeriodicUpdateCheckIntervalMinutes); CheckForQuickStart(); } @@ -195,8 +214,10 @@ public async Task InitializeAsync() /// public void Dispose() { + _periodicUpdateTimer?.Dispose(); _initializationCts?.Cancel(); _initializationCts?.Dispose(); + WeakReferenceMessenger.Default.UnregisterAll(this); GC.SuppressFinalize(this); } @@ -223,7 +244,10 @@ private static NavigationTab LoadInitialTab(IConfigurationProviderService config // Register for messages private void RegisterMessages() { - WeakReferenceMessenger.Default.Register(this); + if (!WeakReferenceMessenger.Default.IsRegistered(this)) + { + WeakReferenceMessenger.Default.RegisterAll(this); + } } /// @@ -237,61 +261,167 @@ private async Task CheckForUpdatesAsync(CancellationToken cancellationToken = de { var settings = userSettingsService.Get(); - // Push settings to update manager (important context for other components) + // 1. check for subscribed pr artifacts if (settings.SubscribedPrNumber.HasValue) { - velopackUpdateManager.SubscribedPrNumber = settings.SubscribedPrNumber; + var prNumber = settings.SubscribedPrNumber.Value; + logger?.LogDebug("User subscribed to PR #{PrNumber}, checking for artifact updates", prNumber); + velopackUpdateManager.SubscribedPrNumber = prNumber; + velopackUpdateManager.SubscribedBranch = null; + + var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (artifactUpdate != null) + { + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + var artifactVersionBase = artifactUpdate.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(artifactVersionBase, currentVersionBase) && + !string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"pr:{prNumber}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("PR #{PrNumber} update available: {Version}", prNumber, artifactUpdate.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.PrUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.PrUpdateNotificationFormat, artifactUpdate.DisplayVersion, prNumber), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(artifactUpdate, null, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + } + + return; + } + + // 2. check for subscribed branch artifacts + if (!string.IsNullOrWhiteSpace(settings.SubscribedBranch)) + { + var branch = settings.SubscribedBranch; + logger?.LogDebug("User subscribed to branch '{Branch}', checking for artifact updates", branch); + velopackUpdateManager.SubscribedBranch = branch; + velopackUpdateManager.SubscribedPrNumber = null; + + var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); + if (artifactUpdate != null) + { + var currentVersionBase = UpdateNotificationViewModel.CurrentAppVersion.Split('+')[0]; + var artifactVersionBase = artifactUpdate.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(artifactVersionBase, currentVersionBase) && + !string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + var updateIdentity = $"branch:{branch}:{artifactVersionBase}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("Branch '{Branch}' update available: {Version}", branch, artifactUpdate.DisplayVersion); + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.BranchUpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.BranchUpdateNotificationFormat, artifactUpdate.DisplayVersion, branch), + autoDismissMilliseconds: null, + actions: + [ + new NotificationAction( + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(artifactUpdate, null, null), + NotificationActionStyle.Primary, + dismissOnExecute: true), + ], + isPersistent: true, + showInBadge: true)); + } + } + + return; } - // 1. Check for standard GitHub releases (Default) - if (string.IsNullOrEmpty(settings.SubscribedBranch)) + // 3. check for standard github releases + velopackUpdateManager.SubscribedPrNumber = null; + velopackUpdateManager.SubscribedBranch = null; + + var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); + if (updateInfo != null) { - var updateInfo = await velopackUpdateManager.CheckForUpdatesAsync(cancellationToken); - if (updateInfo != null) + var version = updateInfo.TargetFullRelease.Version.ToString(); + if (!string.Equals(version, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - logger?.LogInformation("GitHub release update available: {Version}", updateInfo.TargetFullRelease.Version); - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( + var updateIdentity = $"release:{version}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } + + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub release update available: {Version}", version); + notificationService.Show(new NotificationMessage( NotificationType.Info, - "Update Available", - $"A new version ({updateInfo.TargetFullRelease.Version}) is available.", - null, // Persistent + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, version), + autoDismissMilliseconds: null, actions: [ new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, updateInfo, null), NotificationActionStyle.Primary, dismissOnExecute: true), - ]))); + ], + isPersistent: true, + showInBadge: true)); return; } } - else + else if (velopackUpdateManager.HasUpdateAvailableFromGitHub) { - // 2. Check for Subscribed Branch Artifacts - logger?.LogDebug("User subscribed to branch '{Branch}', checking for artifact updates", settings.SubscribedBranch); - velopackUpdateManager.SubscribedBranch = settings.SubscribedBranch; - velopackUpdateManager.SubscribedPrNumber = null; // Clear PR to avoid ambiguity - - var artifactUpdate = await velopackUpdateManager.CheckForArtifactUpdatesAsync(cancellationToken); - - if (artifactUpdate != null) + var githubVersion = velopackUpdateManager.LatestVersionFromGitHub; + if (!string.IsNullOrWhiteSpace(githubVersion) && + !string.Equals(githubVersion, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - var newVersionBase = artifactUpdate.Version.Split('+')[0]; + var updateIdentity = $"github:{githubVersion}"; + if (string.Equals(_lastNotifiedUpdateIdentity, updateIdentity, StringComparison.Ordinal)) + { + logger?.LogDebug("Update notification already shown for {Identity}, skipping duplicate notification", updateIdentity); + return; + } - await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new NotificationMessage( + _lastNotifiedUpdateIdentity = updateIdentity; + logger?.LogInformation("GitHub API release update available: {Version}", githubVersion); + notificationService.Show(new NotificationMessage( NotificationType.Info, - "Branch Update Available", - $"A new build ({newVersionBase}) is available on branch '{settings.SubscribedBranch}'.", - null, // Persistent + AppUpdateConstants.UpdateAvailableNotificationTitle, + string.Format(AppUpdateConstants.ReleaseUpdateNotificationFormat, githubVersion), + autoDismissMilliseconds: null, actions: [ new NotificationAction( - "View Updates", - () => SettingsViewModel.OpenUpdateWindowCommand.Execute(null), + AppUpdateConstants.UpdateAction, + () => _ = PerformOneClickUpdateAsync(null, null, githubVersion), NotificationActionStyle.Primary, dismissOnExecute: true), - ]))); + ], + isPersistent: true, + showInBadge: true)); } } } @@ -301,6 +431,99 @@ await Dispatcher.UIThread.InvokeAsync(() => notificationService.Show(new Notific } } + private async Task PerformOneClickUpdateAsync( + ArtifactUpdateInfo? artifactUpdate, + UpdateInfo? updateInfo, + string? githubVersion) + { + var progressNotificationId = Guid.NewGuid(); + + // show the progress notification immediately + notificationService.Show(new NotificationMessage( + NotificationType.Info, + AppUpdateConstants.UpdatingAppNotificationTitle, + AppUpdateConstants.UpdateStartingMessage, + autoDismissMilliseconds: null, + isPersistent: false, + showInBadge: false) + { + Id = progressNotificationId, + }); + + var progress = new Progress(p => + { + string statusText; + if (!string.IsNullOrWhiteSpace(p.Message)) + { + statusText = p.Message; + } + else if (!string.IsNullOrWhiteSpace(p.Status)) + { + statusText = p.Status; + } + else + { + statusText = $"{p.PercentComplete}%"; + } + + notificationService.Update( + progressNotificationId, + statusText, + AppUpdateConstants.UpdatingAppNotificationTitle); + }); + + try + { + if (artifactUpdate != null) + { + logger?.LogInformation("Starting one-click artifact install: {Version}", artifactUpdate.DisplayVersion); + await velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _initializationCts.Token); + notificationService.Update( + progressNotificationId, + AppUpdateConstants.UpdateCompleteRestartingMessage, + AppUpdateConstants.UpdatingAppNotificationTitle); + } + else if (updateInfo != null) + { + logger?.LogInformation("Starting one-click release update: {Version}", updateInfo.TargetFullRelease.Version); + await velopackUpdateManager.DownloadUpdatesAsync(updateInfo, progress, _initializationCts.Token); + notificationService.Update( + progressNotificationId, + AppUpdateConstants.UpdateDownloadedRestartingMessage, + AppUpdateConstants.UpdatingAppNotificationTitle); + velopackUpdateManager.ApplyUpdatesAndRestart(updateInfo); + } + else if (!string.IsNullOrWhiteSpace(githubVersion)) + { + logger?.LogInformation("Opening update window for GitHub API update: {Version}", githubVersion); + notificationService.Dismiss(progressNotificationId); + OpenUpdateSettings(); + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to install update"); + notificationService.Dismiss(progressNotificationId); + notificationService.ShowError( + AppUpdateConstants.UpdateFailedNotificationTitle, + string.Format(AppUpdateConstants.UpdateFailedNotificationFormat, ex.Message), + autoDismissMs: NotificationConstants.DefaultAutoDismissMs); + } + } + + private void OpenUpdateSettings() + { + SelectTab(NavigationTab.Settings); + if (Dispatcher.UIThread.CheckAccess()) + { + SettingsViewModel.OpenUpdateWindowCommand.Execute(null); + } + else + { + Dispatcher.UIThread.Post(() => SettingsViewModel.OpenUpdateWindowCommand.Execute(null)); + } + } + private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) { try @@ -317,6 +540,42 @@ private async Task CheckForUpdatesInBackgroundAsync(CancellationToken ct) } } + private void RestartPeriodicUpdateTimer(bool enabled, int intervalMinutes) + { + _periodicUpdateTimer?.Dispose(); + _periodicUpdateTimer = null; + + if (!enabled || intervalMinutes <= 0) + { + return; + } + + var clampedInterval = Math.Clamp( + intervalMinutes, + AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes, + AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes); + + var interval = TimeSpan.FromMinutes(clampedInterval); + logger?.LogDebug("Starting periodic update check timer with interval: {Interval}", interval); + + _periodicUpdateTimer = new Timer( + OnPeriodicUpdateTimerCallback, + null, + interval, + interval); + } + + private void OnPeriodicUpdateTimerCallback(object? state) + { + if (_initializationCts.IsCancellationRequested) + { + return; + } + + logger?.LogDebug("Periodic update check timer triggered"); + _ = CheckForUpdatesInBackgroundAsync(_initializationCts.Token); + } + private void CheckForQuickStart() { var settings = userSettingsService.Get(); diff --git a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs index c67e2f4de..6ab5e9417 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/Dialogs/GenericMessageWindow.axaml.cs @@ -1,6 +1,9 @@ using System; using Avalonia; using Avalonia.Controls; +#if DEBUG +using Avalonia.Diagnostics; +#endif using Avalonia.Input; using Avalonia.Markup.Xaml; using GenHub.Common.ViewModels.Dialogs; diff --git a/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml b/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml index 7a1c10fc9..1362960c1 100644 --- a/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml +++ b/GenHub/GenHub/Common/Views/Dialogs/UpdateOptionDialogWindow.axaml @@ -10,6 +10,7 @@ Width="500" SizeToContent="Height" WindowStartupLocation="CenterOwner" SystemDecorations="None" + CanResize="False" TransparencyLevelHint="AcrylicBlur" Background="Transparent" ExtendClientAreaToDecorationsHint="True"> diff --git a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs index e11b82cf0..899d41a32 100644 --- a/GenHub/GenHub/Common/Views/MainWindow.axaml.cs +++ b/GenHub/GenHub/Common/Views/MainWindow.axaml.cs @@ -26,7 +26,7 @@ private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) { if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { - if (e.ClickCount == 2) + if (e.ClickCount == 2 && CanResize) { MaximizeButton_Click(sender, new Avalonia.Interactivity.RoutedEventArgs()); } diff --git a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs index 9732215b2..3b0388c1f 100644 --- a/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs +++ b/GenHub/GenHub/Features/AppUpdate/Services/VelopackUpdateManager.cs @@ -52,11 +52,16 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable private UpdateInfo? _cachedUpdateInfo; private DateTime _lastArtifactCheckTime = DateTime.MinValue; private ArtifactUpdateInfo? _cachedArtifactUpdateInfo; + private int? _cachedArtifactSubscribedPrNumber; + private string? _cachedArtifactSubscribedBranch; private DateTime _lastPrListCheckTime = DateTime.MinValue; private IReadOnlyList? _cachedPrList; private DateTime _lastBranchListCheckTime = DateTime.MinValue; private IReadOnlyList? _cachedBranchList; + private int? _subscribedPrNumber; + private string? _subscribedBranch; + /// public bool HasArtifactUpdateAvailable => _latestArtifactUpdate != null; @@ -64,10 +69,34 @@ public partial class VelopackUpdateManager : IVelopackUpdateManager, IDisposable public ArtifactUpdateInfo? LatestArtifactUpdate => _latestArtifactUpdate; /// - public int? SubscribedPrNumber { get; set; } + public int? SubscribedPrNumber + { + get => _subscribedPrNumber; + set + { + if (_subscribedPrNumber != value) + { + _subscribedPrNumber = value; + _cachedArtifactUpdateInfo = null; + _lastArtifactCheckTime = DateTime.MinValue; + } + } + } /// - public string? SubscribedBranch { get; set; } + public string? SubscribedBranch + { + get => _subscribedBranch; + set + { + if (!string.Equals(_subscribedBranch, value, StringComparison.OrdinalIgnoreCase)) + { + _subscribedBranch = value; + _cachedArtifactUpdateInfo = null; + _lastArtifactCheckTime = DateTime.MinValue; + } + } + } /// public bool IsPrMergedOrClosed { get; private set; } @@ -370,8 +399,13 @@ public string? LatestVersionFromGitHub /// public async Task CheckForArtifactUpdatesAsync(CancellationToken cancellationToken = default) { - // Check cache - if (DateTime.UtcNow - _lastArtifactCheckTime < AppUpdateConstants.CacheDuration) + var targetPrNumber = SubscribedPrNumber; + var targetBranch = SubscribedBranch; + + // check cache + if (DateTime.UtcNow - _lastArtifactCheckTime < AppUpdateConstants.CacheDuration && + _cachedArtifactSubscribedPrNumber == targetPrNumber && + string.Equals(_cachedArtifactSubscribedBranch, targetBranch, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Returning cached artifact update info (checked {TimeLess} ago)", (DateTime.UtcNow - _lastArtifactCheckTime).ToString(@"mm\:ss")); return _cachedArtifactUpdateInfo; @@ -387,34 +421,44 @@ public string? LatestVersionFromGitHub try { - // Reset latest artifact if switching modes/channels - _latestArtifactUpdate = null; + ArtifactUpdateInfo? artifactUpdate = null; - // Priority: - // 1. Subscribed PR - // 2. Subscribed Branch - // 3. Overall latest - if (SubscribedPrNumber.HasValue) + // priority: + // 1. subscribed pr + // 2. subscribed branch + // 3. overall latest + if (targetPrNumber.HasValue) { - _logger.LogInformation("Checking for artifacts for subscribed PR #{PrNumber}", SubscribedPrNumber.Value); + _logger.LogInformation("Checking for artifacts for subscribed PR #{PrNumber}", targetPrNumber.Value); var prs = await GetOpenPullRequestsAsync(cancellationToken); - var subscribedPr = prs.FirstOrDefault(p => p.Number == SubscribedPrNumber.Value); - _latestArtifactUpdate = subscribedPr?.LatestArtifact; + var subscribedPr = prs.FirstOrDefault(p => p.Number == targetPrNumber.Value); + artifactUpdate = subscribedPr?.LatestArtifact; } - else if (!string.IsNullOrEmpty(SubscribedBranch)) + else if (!string.IsNullOrEmpty(targetBranch)) { - _logger.LogInformation("Checking for artifacts for subscribed branch: {Branch}", SubscribedBranch); - _latestArtifactUpdate = await FindLatestArtifactAsync(SubscribedBranch, cancellationToken); + _logger.LogInformation("Checking for artifacts for subscribed branch: {Branch}", targetBranch); + artifactUpdate = await FindLatestArtifactAsync(targetBranch, cancellationToken); } else { _logger.LogInformation("Checking for overall latest artifact"); - _latestArtifactUpdate = await FindLatestArtifactAsync(null, cancellationToken); + artifactUpdate = await FindLatestArtifactAsync(null, cancellationToken); } - _cachedArtifactUpdateInfo = _latestArtifactUpdate; + // verify subscription did not change while awaiting + if (SubscribedPrNumber != targetPrNumber || + !string.Equals(SubscribedBranch, targetBranch, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Subscription changed during artifact check, discarding result"); + return null; + } + + _latestArtifactUpdate = artifactUpdate; + _cachedArtifactUpdateInfo = artifactUpdate; + _cachedArtifactSubscribedPrNumber = targetPrNumber; + _cachedArtifactSubscribedBranch = targetBranch; _lastArtifactCheckTime = DateTime.UtcNow; - return _latestArtifactUpdate; + return artifactUpdate; } catch (Exception ex) { @@ -519,7 +563,10 @@ public async Task> GetOpenPullRequestsAsync(Cance } var prInfos = await Task.WhenAll(prTasks); - results.AddRange(prInfos); + var sortedPrs = prInfos + .OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue) + .ToList(); + results.AddRange(sortedPrs); // Check if subscribed PR is still open subscribedPrFound = results.Any(p => p.Number == SubscribedPrNumber); @@ -859,6 +906,8 @@ public void ClearCache() _cachedUpdateInfo = null; _lastArtifactCheckTime = DateTime.MinValue; _cachedArtifactUpdateInfo = null; + _cachedArtifactSubscribedPrNumber = null; + _cachedArtifactSubscribedBranch = null; _lastPrListCheckTime = DateTime.MinValue; _cachedPrList = null; _lastBranchListCheckTime = DateTime.MinValue; @@ -1529,7 +1578,7 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) } } - _logger.LogWarning("No suitable artifacts found in the last 10 'push' runs for branch {Branch}", branch ?? "any"); + _logger.LogWarning("No suitable artifacts found in workflow runs for branch {Branch}", branch ?? "any"); return null; } catch (Exception ex) @@ -1555,15 +1604,17 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) var shortHash = headSha.Length >= AppConstants.GitShortHashLength ? headSha[..AppConstants.GitShortHashLength] : headSha; var actualBranch = run.TryGetProperty("head_branch", out var b) ? b.GetString() : branch ?? "unknown"; - if (!string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase)) + _logger.LogDebug("Checking run {RunId} ({EventType}) on branch {ActualBranch}", runId, eventType, actualBranch); + + if (!string.IsNullOrEmpty(branch) && !string.Equals(actualBranch, branch, StringComparison.Ordinal)) { - _logger.LogDebug("Skipping run {RunId} ({EventType}) - only 'push' events are valid for branch subscriptions", runId, eventType); + _logger.LogDebug("Skipping run {RunId} ({ActualBranch}) - does not match requested branch {Branch}", runId, actualBranch, branch); return null; } - if (!string.IsNullOrEmpty(branch) && !string.Equals(actualBranch, branch, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(branch) && !string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase) && !string.Equals(eventType, "workflow_dispatch", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Skipping run {RunId} ({ActualBranch}) - does not match requested branch {Branch}", runId, actualBranch, branch); + _logger.LogDebug("Skipping run {RunId} ({EventType}) - not a push or workflow_dispatch event for branch {Branch}", runId, eventType, branch); return null; } @@ -1607,9 +1658,50 @@ private HttpClient CreateConfiguredHttpClientWithToken(SecureString token) return null; } + private bool IsMatchingWorkflowRun(JsonElement run, string? branchName, int? prNumber) + { + var actualBranch = run.TryGetProperty("head_branch", out var b) ? b.GetString() : branchName ?? "unknown"; + var eventType = run.TryGetProperty("event", out var e) ? e.GetString() : "unknown"; + + if (prNumber.HasValue) + { + if (run.TryGetProperty("pull_requests", out var prs) && prs.ValueKind == JsonValueKind.Array) + { + var prCount = 0; + foreach (var pr in prs.EnumerateArray()) + { + prCount++; + if (pr.TryGetProperty("number", out var num) && num.GetInt32() == prNumber.Value) + { + return true; + } + } + + if (prCount > 0) + { + return false; + } + } + + return string.IsNullOrEmpty(branchName) || string.Equals(actualBranch, branchName, StringComparison.Ordinal); + } + + if (!string.IsNullOrEmpty(branchName)) + { + if (!string.Equals(actualBranch, branchName, StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(eventType, "push", StringComparison.OrdinalIgnoreCase) || + string.Equals(eventType, "workflow_dispatch", StringComparison.OrdinalIgnoreCase); + } + + return true; + } + private async Task> FindArtifactsAsync(HttpClient client, string? branchName, int? prNumber, CancellationToken cancellationToken) { - var results = new List(); var owner = AppConstants.GitHubRepositoryOwner; var repo = AppConstants.GitHubRepositoryName; @@ -1618,13 +1710,18 @@ private async Task> FindArtifactsAsync(HttpCli : string.Format(ApiConstants.GitHubApiWorkflowRunsAllFormat, owner, repo); var runsResponse = await SendWithRetryAsync(client, runsUrl, cancellationToken); - if (runsResponse == null || !runsResponse.IsSuccessStatusCode) return []; + if (runsResponse == null || !runsResponse.IsSuccessStatusCode) + { + return []; + } var runsJson = await runsResponse.Content.ReadAsStringAsync(cancellationToken); using var runsDoc = JsonDocument.Parse(runsJson); - var workflowRuns = runsDoc.RootElement.GetProperty("workflow_runs"); + if (!runsDoc.RootElement.TryGetProperty("workflow_runs", out var workflowRuns)) + { + return []; + } - var addedVersions = new HashSet(); var platformFilter = GetCurrentPlatformFilter(); if (platformFilter == null) { @@ -1632,64 +1729,116 @@ private async Task> FindArtifactsAsync(HttpCli return []; } + var results = new List(); + var addedVersions = new HashSet(); + foreach (var run in workflowRuns.EnumerateArray()) { - var runId = run.GetProperty("id").GetInt64(); - var runNum = run.GetProperty("run_number").GetInt32(); - var createdAt = run.GetProperty("created_at").GetDateTimeOffset(); - var headSha = run.GetProperty("head_sha").GetString() ?? string.Empty; - var shortHash = headSha.Length >= 7 ? headSha[..7] : headSha; + if (!IsMatchingWorkflowRun(run, branchName, prNumber)) + { + continue; + } - var artifactsUrl = run.GetProperty("artifacts_url").GetString(); - if (string.IsNullOrEmpty(artifactsUrl)) continue; + await ExtractArtifactsFromWorkflowRunAsync(client, run, prNumber, platformFilter, addedVersions, results, cancellationToken); + } - var artifactsResponse = await SendWithRetryAsync(client, artifactsUrl, cancellationToken); - if (artifactsResponse == null || !artifactsResponse.IsSuccessStatusCode) continue; + return [.. results.OrderByDescending(r => r.CreatedAt)]; + } - var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); - using var artifactsDoc = JsonDocument.Parse(artifactsJson); - var artifacts = artifactsDoc.RootElement.GetProperty("artifacts"); + private async Task ExtractArtifactsFromWorkflowRunAsync( + HttpClient client, + JsonElement run, + int? prNumber, + string platformFilter, + HashSet addedVersions, + List results, + CancellationToken cancellationToken) + { + var artifactsUrl = run.TryGetProperty("artifacts_url", out var u) ? u.GetString() : null; + if (string.IsNullOrEmpty(artifactsUrl)) + { + return; + } - foreach (var artifact in artifacts.EnumerateArray()) - { - var name = artifact.GetProperty("name").GetString(); - if (string.IsNullOrEmpty(name) || !name.Contains("velopack", StringComparison.OrdinalIgnoreCase)) continue; + var artifactsResponse = await SendWithRetryAsync(client, artifactsUrl, cancellationToken); + if (artifactsResponse == null || !artifactsResponse.IsSuccessStatusCode) + { + return; + } - if (!name.Contains(platformFilter, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Skipping artifact {Name} - doesn't match platform {Platform}", name, platformFilter); - continue; - } + var artifactsJson = await artifactsResponse.Content.ReadAsStringAsync(cancellationToken); + using var artifactsDoc = JsonDocument.Parse(artifactsJson); + if (!artifactsDoc.RootElement.TryGetProperty("artifacts", out var artifacts)) + { + return; + } - var version = ExtractVersionFromArtifactName(name) ?? $"0.0.0-ci.{runNum}"; - var uniqueKey = $"{version}|{shortHash}"; - if (!addedVersions.Add(uniqueKey)) - { - _logger.LogDebug("Skipping duplicate artifact: {Version} ({Hash})", version, shortHash); - continue; - } + if (!run.TryGetProperty("id", out var idProp) || !idProp.TryGetInt64(out var runId) || + !run.TryGetProperty("run_number", out var runNumProp) || !runNumProp.TryGetInt32(out var runNum) || + !run.TryGetProperty("created_at", out var createdAtProp) || !createdAtProp.TryGetDateTimeOffset(out var createdAt)) + { + return; + } - var id = artifact.GetProperty("id").GetInt64(); - var size = artifact.GetProperty("size_in_bytes").GetInt64(); - var downloadUrl = artifact.GetProperty("archive_download_url").GetString(); - var workflowRunUrl = run.GetProperty("html_url").GetString() ?? string.Empty; - - var info = new ArtifactUpdateInfo( - Version: version, - GitHash: shortHash, - PullRequestNumber: prNumber, - WorkflowRunId: runId, - WorkflowRunUrl: workflowRunUrl, - ArtifactId: id, - ArtifactName: name ?? "Unknown", - CreatedAt: createdAt.UtcDateTime, - DownloadUrl: downloadUrl, - Size: size); + var headSha = run.TryGetProperty("head_sha", out var sha) ? sha.GetString() ?? string.Empty : string.Empty; + var shortHash = headSha.Length >= AppConstants.GitShortHashLength ? headSha[..AppConstants.GitShortHashLength] : headSha; + var workflowRunUrl = run.TryGetProperty("html_url", out var html) ? html.GetString() ?? string.Empty : string.Empty; + foreach (var artifact in artifacts.EnumerateArray()) + { + var info = TryParseArtifactUpdateInfo(artifact, runId, runNum, createdAt.UtcDateTime, shortHash, workflowRunUrl, prNumber, platformFilter, addedVersions); + if (info != null) + { results.Add(info); } } + } - return [.. results.OrderByDescending(r => r.CreatedAt)]; + private ArtifactUpdateInfo? TryParseArtifactUpdateInfo( + JsonElement artifact, + long runId, + int runNum, + DateTime createdAtUtc, + string shortHash, + string workflowRunUrl, + int? prNumber, + string platformFilter, + HashSet addedVersions) + { + var name = artifact.TryGetProperty("name", out var n) ? n.GetString() : null; + if (string.IsNullOrEmpty(name) || !name.Contains("velopack", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!name.Contains(platformFilter, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Skipping artifact {Name} - doesn't match platform {Platform}", name, platformFilter); + return null; + } + + var version = ExtractVersionFromArtifactName(name) ?? $"0.0.0-ci.{runNum}"; + var uniqueKey = $"{version}|{shortHash}"; + if (!addedVersions.Add(uniqueKey)) + { + _logger.LogDebug("Skipping duplicate artifact: {Version} ({Hash})", version, shortHash); + return null; + } + + var id = artifact.GetProperty("id").GetInt64(); + var size = artifact.GetProperty("size_in_bytes").GetInt64(); + var downloadUrl = artifact.TryGetProperty("archive_download_url", out var dl) ? dl.GetString() : null; + + return new ArtifactUpdateInfo( + Version: version, + GitHash: shortHash, + PullRequestNumber: prNumber, + WorkflowRunId: runId, + WorkflowRunUrl: workflowRunUrl, + ArtifactId: id, + ArtifactName: name, + CreatedAt: createdAtUtc, + DownloadUrl: downloadUrl, + Size: size); } } diff --git a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs index ed34fe005..d6d9461c5 100644 --- a/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs +++ b/GenHub/GenHub/Features/AppUpdate/ViewModels/UpdateNotificationViewModel.cs @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GenHub.Core.Constants; +using GenHub.Core.Helpers; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.GitHub; using GenHub.Core.Models.AppUpdate; @@ -25,34 +26,58 @@ namespace GenHub.Features.AppUpdate.ViewModels; /// public partial class UpdateNotificationViewModel : ObservableObject, IDisposable { - private readonly IVelopackUpdateManager _velopackUpdateManager; - private readonly ILogger _logger; - private readonly IUserSettingsService _userSettingsService; - private readonly CancellationTokenSource _cancellationTokenSource; - private UpdateInfo? _currentUpdateInfo; + private static readonly Lazy CachedCurrentAppVersion = new(() => + { + try + { + // get actual installed version from velopack + var updateManager = new UpdateManager(new SimpleWebSource(string.Empty)); + var currentVersion = updateManager.CurrentVersion; + return currentVersion?.ToString() ?? AppConstants.AppVersion; + } + catch + { + // fallback to compile-time version if velopack fails + return AppConstants.AppVersion; + } + }); /// /// Gets the current application version. /// - public static string CurrentAppVersion + public static string CurrentAppVersion => CachedCurrentAppVersion.Value; + + /// + /// Gets the formatted display string of the currently installed application version. + /// + public static string DisplayCurrentVersion { get { - try - { - // Get actual installed version from Velopack - var updateManager = new UpdateManager(new SimpleWebSource(string.Empty)); - var currentVersion = updateManager.CurrentVersion; - return currentVersion?.ToString() ?? AppConstants.AppVersion; - } - catch + var version = CurrentAppVersion; + if (string.IsNullOrWhiteSpace(version)) { - // Fallback to compile-time version if Velopack fails - return AppConstants.AppVersion; + return "0.0.0"; } + + var cleanVersion = version.Split('+')[0].TrimStart('v', 'V'); + return $"v{cleanVersion}"; } } + /// + /// Gets the formatted display string of the currently installed application version for instance data binding. + /// + public string InstalledVersionDisplay => DisplayCurrentVersion; + + private readonly IVelopackUpdateManager _velopackUpdateManager; + private readonly ILogger _logger; + private readonly IUserSettingsService _userSettingsService; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly List _allPullRequests = []; + private CancellationTokenSource? _loadArtifactsCts; + private UpdateInfo? _currentUpdateInfo; + /// /// Gets or sets the status message. /// @@ -126,6 +151,39 @@ public static string CurrentAppVersion [ObservableProperty] private ObservableCollection _availablePullRequests = []; + /// + /// Gets or sets the selected tab index (0 = Update, 1 = Browse Builds). + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsBrowseTabSelected))] + private int _selectedTabIndex; + + /// + /// Gets a value indicating whether the browse builds tab is selected. + /// + public bool IsBrowseTabSelected => SelectedTabIndex == AppUpdateConstants.BrowseBuildsTabIndex; + + /// + /// Gets the list of available sort options for pull requests. + /// + public IReadOnlyList AvailableSortOptions { get; } = + [ + AppUpdateConstants.SortOptionLastUpdated, + AppUpdateConstants.SortOptionPrNumberDesc, + AppUpdateConstants.SortOptionPrNumberAsc, + ]; + + /// + /// Gets or sets the selected sort option for pull requests. + /// + [ObservableProperty] + private string _selectedSortOption = AppUpdateConstants.SortOptionLastUpdated; + + partial void OnSelectedSortOptionChanged(string value) + { + ApplyPullRequestSorting(); + } + /// /// Gets or sets the currently subscribed PR. /// @@ -239,14 +297,14 @@ private async Task ForceRefresh() { await CheckForUpdatesAsync(); - // Also refresh PRs/Branches if in browse mode + // also refresh prs and branches if in browse mode if (HasPat) { await LoadPullRequestsAsync(); await LoadBranchesAsync(); } - // Refresh artifacts for current subscription + // refresh artifacts for current subscription if (IsSubscribedToAny) { await LoadArtifactsForSubscribedItemAsync(); @@ -275,22 +333,40 @@ public UpdateNotificationViewModel( ManualRefreshCommand = new AsyncRelayCommand(ManualRefreshAsync, () => !IsChecking); DismissCommand = new RelayCommand(DismissUpdate); - // Check if PAT is available + // check if pat is available HasPat = gitHubTokenStorage?.HasToken() == true; _logger.LogInformation("UpdateNotificationViewModel initialized with Velopack (HasPat={HasPat})", HasPat); - // Monitor collection changes to update placeholder text + // monitor collection changes to update placeholder text AvailableVersions.CollectionChanged += (s, e) => OnPropertyChanged(nameof(VersionPlaceholderText)); - // Automatically check for updates and load PRs when dialog opens + // automatically check for updates and load prs when dialog opens _ = InitializeAsync(); } private async Task LoadArtifactsForSubscribedItemAsync() { - // Cancel any previous loading if possible, or just guard - if (IsLoadingVersions) return; // Simple guard, could be improved with cancellation token + // cancel any previous in-flight load + _loadArtifactsCts?.Cancel(); + _loadArtifactsCts?.Dispose(); + _loadArtifactsCts = null; + + var targetPr = SubscribedPr; + var targetPrNumber = targetPr?.Number ?? _velopackUpdateManager.SubscribedPrNumber; + var targetBranch = SubscribedBranch; + + if (targetPrNumber == null && string.IsNullOrEmpty(targetBranch)) + { + IsLoadingVersions = false; + AvailableVersions.Clear(); + SelectedVersion = null; + return; + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); + _loadArtifactsCts = cts; + var token = cts.Token; IsLoadingVersions = true; AvailableVersions.Clear(); @@ -300,18 +376,25 @@ private async Task LoadArtifactsForSubscribedItemAsync() { IReadOnlyList artifacts = []; - if (SubscribedPr != null) + if (targetPrNumber.HasValue) { - artifacts = await _velopackUpdateManager.GetArtifactsForPullRequestAsync(SubscribedPr.Number, _cancellationTokenSource.Token); + _logger.LogInformation("Loading artifacts for PR #{PrNumber}", targetPrNumber.Value); + artifacts = await _velopackUpdateManager.GetArtifactsForPullRequestAsync(targetPrNumber.Value, token); } - else if (!string.IsNullOrEmpty(SubscribedBranch)) + else if (!string.IsNullOrEmpty(targetBranch)) + { + _logger.LogInformation("Loading artifacts for branch '{Branch}'", targetBranch); + artifacts = await _velopackUpdateManager.GetArtifactsForBranchAsync(targetBranch, token); + } + + if (token.IsCancellationRequested) { - artifacts = await _velopackUpdateManager.GetArtifactsForBranchAsync(SubscribedBranch, _cancellationTokenSource.Token); + return; } _logger.LogInformation("Received {Count} platform-compatible artifacts from update manager", artifacts.Count); - // Use HashSet to prevent duplicates based on artifact ID + // use hashset to prevent duplicates based on artifact id var addedArtifactIds = new HashSet(); foreach (var artifact in artifacts) { @@ -328,15 +411,29 @@ private async Task LoadArtifactsForSubscribedItemAsync() _logger.LogInformation("Loaded {Count} artifacts into AvailableVersions", AvailableVersions.Count); - // Don't auto-select to avoid duplicate display in ComboBox + // auto-select latest version for improved user experience + if (AvailableVersions.Count > 0) + { + SelectedVersion = AvailableVersions[0]; + } + } + catch (OperationCanceledException) + { + _logger.LogDebug("Artifact loading cancelled for subscription change"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to load available versions"); + if (!token.IsCancellationRequested) + { + _logger.LogError(ex, "Failed to load available versions"); + } } finally { - IsLoadingVersions = false; + if (ReferenceEquals(_loadArtifactsCts, cts)) + { + IsLoadingVersions = false; + } } } @@ -345,7 +442,7 @@ private async Task LoadArtifactsForSubscribedItemAsync() /// private async Task InitializeAsync() { - // Load subscribed PR and Branch from settings + // load subscribed pr and branch from settings var settings = _userSettingsService.Get(); if (settings.SubscribedPrNumber.HasValue) { @@ -359,16 +456,16 @@ private async Task InitializeAsync() _logger.LogInformation("Loaded subscribed branch '{Branch}' from settings", settings.SubscribedBranch); } - // Load data if we have a PAT + // load data if we have a pat if (HasPat) { - // Initial check/load + // initial check and load await Task.WhenAll( LoadPullRequestsAsync(), LoadBranchesAsync()); } - // Now check for updates - subscriptions will be properly populated + // check for updates after subscriptions are populated await CheckForUpdatesAsync(); } @@ -419,14 +516,14 @@ public string DisplayLatestVersion return GameClientConstants.UnknownVersion; } - // 1. PR Update takes precedence + // 1. pr update takes precedence if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { return SubscribedPr.LatestArtifact.DisplayVersion; } - // 2. Branch Update + // 2. branch update if (!string.IsNullOrEmpty(SubscribedBranch)) { return LatestVersion.StartsWith(SubscribedBranch, StringComparison.OrdinalIgnoreCase) @@ -445,34 +542,135 @@ public string DisplayLatestVersion /// public void Dispose() { + _loadArtifactsCts?.Cancel(); + _loadArtifactsCts?.Dispose(); + _loadArtifactsCts = null; + _cancellationTokenSource.Cancel(); _cancellationTokenSource.Dispose(); GC.SuppressFinalize(this); } - /// - /// Extracts the workflow run number from a version string like "0.0.641-pr241". - /// - private static int ExtractRunNumber(string version) + private void ProcessPrArtifactUpdate(ArtifactUpdateInfo artifact, int prNumber) + { + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var prVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(prVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = prVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + StatusMessage = $"New PR build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: {Version}", prNumber, artifact.DisplayVersion); + return; + } + + StatusMessage = $"You dismissed the update for PR #{prNumber}"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for PR #{prNumber}"; + } + + private void ProcessBranchArtifactUpdate(ArtifactUpdateInfo artifact, string branch) + { + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var branchVersionBase = artifact.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(branchVersionBase, currentVersionBase)) + { + var settings = _userSettingsService.Get(); + if (!string.Equals(branchVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) + { + IsUpdateAvailable = true; + LatestVersion = branchVersionBase; + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{branch}"; + StatusMessage = $"New {branch} build available: {artifact.DisplayVersion}"; + _logger.LogInformation("Branch '{Branch}' has new build: {Version}", branch, LatestVersion); + return; + } + + StatusMessage = $"You dismissed the update for branch '{branch}'"; + return; + } + + IsUpdateAvailable = false; + StatusMessage = $"You are on the latest build for {branch}"; + } + + partial void OnSelectedVersionChanged(ArtifactUpdateInfo? value) { - // Try to extract the run number before the PR suffix - var match = System.Text.RegularExpressions.Regex.Match(version, @"(\d+)(?:-pr\d+|-\w+)?$"); - if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber)) + UpdateCommandStates(); + + if (value == null) { - return runNumber; + return; } - // Fallback: try to parse the entire version as a number - var parts = version.Split('.', '-', '+'); - foreach (var part in parts.Reverse()) + var currentVersionBase = CurrentAppVersion.Split('+')[0]; + var selectedVersionBase = value.Version.Split('+')[0]; + + if (AppUpdateVersionHelper.IsArtifactVersionNewer(selectedVersionBase, currentVersionBase)) { - if (int.TryParse(part, out var number)) + var settings = _userSettingsService.Get(); + if (!string.Equals(selectedVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) { - return number; + IsUpdateAvailable = true; + LatestVersion = selectedVersionBase; + if (value.PullRequestNumber.HasValue) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{value.PullRequestNumber.Value}"; + StatusMessage = $"New PR build available: {value.DisplayVersion}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; + StatusMessage = $"New {SubscribedBranch} build available: {value.DisplayVersion}"; + } + else + { + StatusMessage = $"New build available: {value.DisplayVersion}"; + } + + return; } + + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + StatusMessage = $"You dismissed update {value.DisplayVersion}"; + return; } - return 0; + var currentRun = AppUpdateVersionHelper.ExtractRunNumber(currentVersionBase); + var selectedRun = AppUpdateVersionHelper.ExtractRunNumber(selectedVersionBase); + + if (currentRun > 0 && selectedRun > 0 && currentRun == selectedRun) + { + IsUpdateAvailable = false; + if (value.PullRequestNumber.HasValue) + { + StatusMessage = $"You are on the latest build for PR #{value.PullRequestNumber.Value}"; + } + else if (!string.IsNullOrEmpty(SubscribedBranch)) + { + StatusMessage = $"You are on the latest build for {SubscribedBranch}"; + } + else + { + StatusMessage = $"You are on the latest build ({value.DisplayVersion})"; + } + } + else + { + IsUpdateAvailable = false; + StatusMessage = $"Selected build: {value.DisplayVersion}"; + } } /// @@ -496,86 +694,32 @@ private async Task CheckForUpdatesAsync() _logger.LogInformation("Starting Velopack update check"); - // Check if subscribed to a PR + // check if subscribed to a pr if (SubscribedPr != null) { if (SubscribedPr.LatestArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = SubscribedPr.LatestArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {SubscribedPr.LatestArtifact.DisplayVersion}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(SubscribedPr.LatestArtifact, SubscribedPr.Number); return; } - // Try to fetch artifact for update check + // try to fetch artifact for update check _logger.LogInformation("PR #{PrNumber} has no cached artifact, fetching for update check", SubscribedPr.Number); var prArtifact = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (prArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var prVersionBase = prArtifact.Version.Split('+')[0]; - - // Extract run numbers for numeric comparison - var currentRun = ExtractRunNumber(currentVersionBase); - var prRun = ExtractRunNumber(prVersionBase); - - _logger.LogDebug("Comparing fetched PR #{PrNumber} versions: current run #{CurrentRun} vs new run #{PrRun}", SubscribedPr.Number, currentRun, prRun); - - if (prRun > currentRun) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(prVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = prVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/pull/{SubscribedPr.Number}"; - StatusMessage = $"New PR build available: {prArtifact.DisplayVersion}"; - _logger.LogInformation("Fetched PR #{PrNumber} artifact, new build available: run #{PrRun} (current: #{CurrentRun})", SubscribedPr.Number, prRun, currentRun); - return; - } - - StatusMessage = $"You dismissed the update for PR #{SubscribedPr.Number}"; - return; - } - - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for PR #{SubscribedPr.Number}"; + ProcessPrArtifactUpdate(prArtifact, SubscribedPr.Number); return; } - // If subscribed to PR but no artifact found, don't fall through to main release + // if subscribed to pr but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to PR #{PrNumber} but no artifact available yet", SubscribedPr.Number); StatusMessage = $"Waiting for PR #{SubscribedPr.Number} build..."; IsUpdateAvailable = false; return; } - // Check Branch updates if subscribed + // check branch updates if subscribed if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Checking for artifact updates on branch: {Branch}", SubscribedBranch); @@ -583,38 +727,18 @@ private async Task CheckForUpdatesAsync() if (branchArtifact != null) { - var currentVersionBase = CurrentAppVersion.Split('+')[0]; - var artifactVersionBase = branchArtifact.Version.Split('+')[0]; - - if (!string.Equals(artifactVersionBase, currentVersionBase, StringComparison.OrdinalIgnoreCase)) - { - var settings = _userSettingsService.Get(); - if (!string.Equals(artifactVersionBase, settings.DismissedUpdateVersion, StringComparison.OrdinalIgnoreCase)) - { - IsUpdateAvailable = true; - LatestVersion = artifactVersionBase; - ReleaseNotesUrl = $"{AppConstants.GitHubRepositoryUrl}/tree/{SubscribedBranch}"; - StatusMessage = $"New {SubscribedBranch} build available: {branchArtifact.Version}"; - _logger.LogInformation("Branch '{Branch}' has new build: {Version}", SubscribedBranch, LatestVersion); - return; - } - } - else - { - IsUpdateAvailable = false; - StatusMessage = $"You are on the latest build for {SubscribedBranch}"; - return; - } + ProcessBranchArtifactUpdate(branchArtifact, SubscribedBranch); + return; } - // If subscribed to branch but no artifact found, don't fall through to main release + // if subscribed to branch but no artifact found, do not fall through to main release _logger.LogInformation("Subscribed to branch '{Branch}' but no artifact available yet", SubscribedBranch); StatusMessage = $"Waiting for {SubscribedBranch} build..."; IsUpdateAvailable = false; return; } - // Check main branch releases + // check main branch releases _currentUpdateInfo = await _velopackUpdateManager.CheckForUpdatesAsync(_cancellationTokenSource.Token); if (_currentUpdateInfo != null) @@ -681,7 +805,7 @@ private async Task ManualRefreshAsync() _logger.LogInformation("Manual refresh requested - clearing cache and dismissal status"); - // Clear dismissal status in settings so the user can see the update again + // clear dismissal status in settings so the user can see the update again var settings = _userSettingsService.Get(); if (!string.IsNullOrEmpty(settings.DismissedUpdateVersion)) { @@ -689,10 +813,10 @@ private async Task ManualRefreshAsync() await _userSettingsService.SaveAsync(); } - // Clear manager cache + // clear manager cache _velopackUpdateManager.ClearCache(); - // Reload data + // reload data if (HasPat) { await Task.WhenAll( @@ -703,6 +827,41 @@ await Task.WhenAll( await CheckForUpdatesAsync(); } + /// + /// Shows the update tab. + /// + [RelayCommand] + private void ShowUpdateTab() + { + SelectedTabIndex = AppUpdateConstants.UpdateTabIndex; + } + + /// + /// Shows the browse builds tab. + /// + [RelayCommand] + private void ShowBrowseBuildsTab() + { + SelectedTabIndex = AppUpdateConstants.BrowseBuildsTabIndex; + } + + /// + /// Selects the specified tab by index (0 = Update, 1 = Browse Builds). + /// + /// The tab index to select. + [RelayCommand] + private void SelectTab(object? parameter) + { + if (parameter is int i) + { + SelectedTabIndex = Math.Clamp(i, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + else if (parameter is string s && int.TryParse(s, out var parsed)) + { + SelectedTabIndex = Math.Clamp(parsed, AppUpdateConstants.UpdateTabIndex, AppUpdateConstants.MaxTabIndex); + } + } + /// /// Opens the release notes in the default browser. /// @@ -722,6 +881,29 @@ private void ViewReleaseNotes() } } + /// + /// Opens the specified pull request in the default browser. + /// + /// The PR number to open. + [RelayCommand] + private void OpenPullRequestUrl(int prNumber) + { + if (prNumber <= 0) + { + return; + } + + var url = $"{AppConstants.GitHubRepositoryUrl}/pull/{prNumber}"; + try + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open browser for PR #{PrNumber}", prNumber); + } + } + /// /// Downloads and applies the update using Velopack. /// @@ -733,7 +915,7 @@ private async Task InstallUpdateAsync() return; } - // 0. Handle Explicitly Selected Version + // 0. handle explicitly selected version if (SelectedVersion != null) { _logger.LogInformation("Installing selected artifact version: {Version}", SelectedVersion.DisplayVersion); @@ -741,7 +923,7 @@ private async Task InstallUpdateAsync() return; } - // 1. Handle PR Artifact Update (Auto-latest) + // 1. handle pr artifact update if (SubscribedPr?.LatestArtifact != null && string.Equals(SubscribedPr.LatestArtifact.Version, LatestVersion, StringComparison.OrdinalIgnoreCase)) { @@ -750,7 +932,7 @@ private async Task InstallUpdateAsync() return; } - // 1.5 Handle Branch Artifact Update (Auto-latest) + // 1.5 handle branch artifact update if (!string.IsNullOrEmpty(SubscribedBranch)) { _logger.LogInformation("Installing Branch '{Branch}' artifact update", SubscribedBranch); @@ -758,7 +940,7 @@ private async Task InstallUpdateAsync() return; } - // 2. Handle Standard Velopack Update + // 2. handle standard velopack update if (_currentUpdateInfo == null) { _logger.LogError("Cannot install update - UpdateInfo is null (app not installed via Setup.exe)"); @@ -858,10 +1040,10 @@ private async Task InstallPrArtifactAsync() ArtifactUpdateInfo? artifactToInstall = SubscribedPr.LatestArtifact; if (artifactToInstall == null) { - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Try to fetch the latest artifact for the PR + // try to fetch the latest artifact for the pr artifactToInstall = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactToInstall == null) { @@ -875,7 +1057,7 @@ private async Task InstallPrArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactToInstall, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -932,10 +1114,10 @@ private async Task InstallBranchArtifactAsync() }); }); - // Clear cache to force fresh check + // clear cache to force fresh check _velopackUpdateManager.ClearCache(); - // Check for latest artifact for the subscribed branch + // check for latest artifact for the subscribed branch var artifactUpdate = await _velopackUpdateManager.CheckForArtifactUpdatesAsync(_cancellationTokenSource.Token); if (artifactUpdate == null) { @@ -948,7 +1130,7 @@ private async Task InstallBranchArtifactAsync() await _velopackUpdateManager.InstallArtifactAsync(artifactUpdate, progress, _cancellationTokenSource.Token); - // App will restart, this code won't execute + // app will restart, this code will not execute } catch (Exception ex) { @@ -992,7 +1174,7 @@ private async Task InstallArtifactAsync(ArtifactUpdateInfo artifact) await _velopackUpdateManager.InstallArtifactAsync(artifact, progress, _cancellationTokenSource.Token); - // App will restart + // app will restart } catch (Exception ex) { @@ -1089,10 +1271,9 @@ private async Task LoadPullRequestsAsync() await Dispatcher.UIThread.InvokeAsync(() => { - foreach (var pr in prs) - { - AvailablePullRequests.Add(pr); - } + _allPullRequests.Clear(); + _allPullRequests.AddRange(prs); + ApplyPullRequestSorting(); }); if (_velopackUpdateManager.IsPrMergedOrClosed && _velopackUpdateManager.SubscribedPrNumber.HasValue) @@ -1102,9 +1283,13 @@ await Dispatcher.UIThread.InvokeAsync(() => _logger.LogInformation("Subscribed PR has been merged/closed, showing warning"); } - if (_velopackUpdateManager.SubscribedPrNumber.HasValue && SubscribedPr == null) + if (_velopackUpdateManager.SubscribedPrNumber.HasValue) { - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber); + var matchingPr = AvailablePullRequests.FirstOrDefault(p => p.Number == _velopackUpdateManager.SubscribedPrNumber.Value); + if (matchingPr != null && (SubscribedPr == null || SubscribedPr.Number == matchingPr.Number)) + { + SubscribedPr = matchingPr; + } } } catch (Exception ex) @@ -1118,6 +1303,33 @@ await Dispatcher.UIThread.InvokeAsync(() => } } + private void ApplyPullRequestSorting() + { + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count == 0) + { + return; + } + + if (_allPullRequests.Count == 0 && AvailablePullRequests.Count > 0) + { + _allPullRequests.AddRange(AvailablePullRequests); + } + + IEnumerable sorted = SelectedSortOption switch + { + AppUpdateConstants.SortOptionPrNumberDesc => _allPullRequests.OrderByDescending(p => p.Number), + AppUpdateConstants.SortOptionPrNumberAsc => _allPullRequests.OrderBy(p => p.Number), + _ => _allPullRequests.OrderByDescending(p => p.UpdatedAt ?? DateTimeOffset.MinValue), + }; + + var sortedList = sorted.ToList(); + AvailablePullRequests.Clear(); + foreach (var pr in sortedList) + { + AvailablePullRequests.Add(pr); + } + } + [RelayCommand] private async Task LoadBranchesAsync() { @@ -1154,11 +1366,19 @@ await Dispatcher.UIThread.InvokeAsync(() => private void SubscribeToPr(int prNumber) { _velopackUpdateManager.SubscribedPrNumber = prNumber; - SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber); + _velopackUpdateManager.SubscribedBranch = null; SubscribedBranch = null; + SubscribedPr = AvailablePullRequests.FirstOrDefault(p => p.Number == prNumber) ?? new PullRequestInfo + { + Number = prNumber, + Title = $"PR #{prNumber}", + BranchName = "unknown", + Author = "unknown", + State = "open", + }; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1168,11 +1388,8 @@ private void SubscribeToPr(int prNumber) }); _ = _userSettingsService.SaveAsync(); - if (SubscribedPr != null) - { - StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; - _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); - } + StatusMessage = $"Subscribed to PR #{prNumber}: {SubscribedPr.Title}"; + _logger.LogInformation("Subscribed to PR #{PrNumber}", prNumber); } [RelayCommand] @@ -1180,12 +1397,13 @@ private void SubscribeToBranch(string branchName) { if (string.IsNullOrEmpty(branchName)) return; - SubscribedBranch = branchName; _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = branchName; SubscribedPr = null; + SubscribedBranch = branchName; ShowPrMergedWarning = false; - // Clear artifact cache to force fresh check + // clear artifact cache to force fresh check _velopackUpdateManager.ClearCache(); _userSettingsService.Update(settings => @@ -1201,6 +1419,7 @@ private void SubscribeToBranch(string branchName) partial void OnSubscribedBranchChanged(string? value) { + _velopackUpdateManager.SubscribedBranch = value; _ = LoadArtifactsForSubscribedItemAsync(); OnPropertyChanged(nameof(IsSubscribedToAny)); UpdateCommandStates(); @@ -1220,9 +1439,15 @@ partial void OnSubscribedPrChanged(PullRequestInfo? value) private void Unsubscribe() { _velopackUpdateManager.SubscribedPrNumber = null; + _velopackUpdateManager.SubscribedBranch = null; SubscribedPr = null; SubscribedBranch = null; + SelectedVersion = null; ShowPrMergedWarning = false; + IsUpdateAvailable = false; + LatestVersion = string.Empty; + ReleaseNotesUrl = string.Empty; + _currentUpdateInfo = null; StatusMessage = "Switched to MAIN branch updates"; _userSettingsService.Update(settings => @@ -1233,6 +1458,7 @@ private void Unsubscribe() _ = _userSettingsService.SaveAsync(); _logger.LogInformation("Unsubscribed from dev builds, switched to MAIN"); + _ = CheckForUpdatesAsync(); } [RelayCommand] diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml index fc4a522ba..63c35be95 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationView.axaml @@ -11,39 +11,128 @@ - - + + + + + + + + + - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + diff --git a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs index 5fd0a2bf2..ba60939db 100644 --- a/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs +++ b/GenHub/GenHub/Features/AppUpdate/Views/UpdateNotificationWindow.axaml.cs @@ -62,23 +62,43 @@ public async Task InitializeAsync() private void InitializeComponent() => AvaloniaXamlLoader.Load(this); + /// + /// Handles the maximize/restore button click event. + /// + /// The sender. + /// The event args. + private void MaximizeButton_Click(object? sender, RoutedEventArgs e) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + } + /// /// Handles the close button click event. /// /// The sender. /// The event args. - private void CloseButton_Click(object sender, RoutedEventArgs e) + private void CloseButton_Click(object? sender, RoutedEventArgs e) { Close(); } /// - /// Handles pointer pressed event for the title bar to enable window dragging. + /// Handles pointer pressed event for the title bar to enable window dragging and double-click maximize. /// /// The sender. /// The pointer event args. - private void TitleBar_PointerPressed(object sender, PointerPressedEventArgs e) + private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e) { - BeginMoveDrag(e); + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2) + { + MaximizeButton_Click(sender, new RoutedEventArgs()); + } + else + { + BeginMoveDrag(e); + } + } } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index be8788503..9bc5bd496 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -97,7 +97,7 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var archive = ArchiveFactory.Open(archivePath); + using var archive = ArchiveFactory.Open(fileInfo); foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs index 1a2a4b154..53cdf9437 100644 --- a/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs +++ b/GenHub/GenHub/Features/Content/Services/ContentOrchestrator.cs @@ -108,7 +108,7 @@ public async Task>> SearchAsync _logger.LogDebug("Starting orchestrated content search with query: {SearchTerm}, ContentType: {ContentType}", query.SearchTerm, query.ContentType); // Check cache first - var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.Skip}::{query.Take}::{query.SortOrder}"; + var cacheKey = $"search::{query.ProviderName}::{query.SearchTerm}::{query.ContentType}::{query.TargetGame}::{query.AuthorName}::{query.GitHubAuthor}::{query.Language}::{query.Skip}::{query.Take}::{query.SortOrder}"; var cachedResults = await _cache.GetAsync>(cacheKey, cancellationToken); if (cachedResults != null) { @@ -187,8 +187,19 @@ public async Task>> SearchAsync // than an exception, which would otherwise surface here as an empty successful search. cancellationToken.ThrowIfCancellationRequested(); + // Deduplicate results by manifest ID across providers before sorting and pagination, + // preferring specialized publisher providers over generic GitHub providers. + var deduplicatedResults = allResults + .GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase) + .Select(g => g + .OrderByDescending(r => + !string.Equals(r.ProviderName, ContentSourceNames.GitHubDiscoverer, StringComparison.OrdinalIgnoreCase) && + !string.Equals(r.ProviderName, ContentSourceNames.GitHubReleasesDiscoverer, StringComparison.OrdinalIgnoreCase) ? 1 : 0) + .First()) + .ToList(); + // Apply orchestrator-level sorting and pagination - var sortedResults = ApplySorting(allResults, query.SortOrder) + var sortedResults = ApplySorting(deduplicatedResults, query.SortOrder) .Skip(query.Skip) .Take(query.Take) .ToList(); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index 1814c5362..5d8578b60 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -381,7 +381,7 @@ private async Task ExtractArchiveAsync( await Task.Run( () => { - using var archive = ArchiveFactory.Open(archiveFile); + using var archive = ArchiveFactory.Open(new FileInfo(archiveFile)); int totalEntries = archive.Entries.Count(e => !e.IsDirectory); int currentEntry = 0; diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs index f91075390..e7ac16dc3 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubResolver.cs @@ -186,6 +186,11 @@ await manifest.AddRemoteFileAsync( } var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(release.TagName)) + { + builtManifest.Version = release.TagName; + } + logger.LogInformation("GitHubResolver: Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } @@ -373,6 +378,11 @@ await manifest.AddRemoteFileAsync( logger.LogInformation("Successfully resolved single release asset: {AssetName}", asset.Name); var builtManifest = manifest.Build(); + if (!string.IsNullOrEmpty(tag)) + { + builtManifest.Version = tag; + } + logger.LogInformation("GitHubResolver (Single Asset): Built manifest with ID: {ManifestId}", builtManifest.Id); return OperationResult.CreateSuccess(builtManifest); } diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs index 411041ef5..0d62e14ec 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/PublisherManifestFactoryResolver.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; using Microsoft.Extensions.Logging; @@ -28,14 +28,30 @@ public class PublisherManifestFactoryResolver(IEnumerable().FirstOrDefault(); + if (fallbackFactory != null) + { + logger.LogInformation( + "Resolved fallback {FactoryType} for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", + fallbackFactory.GetType().Name, + manifest.Id, + manifest.Publisher?.PublisherType ?? "unknown", + manifest.ContentType); + return fallbackFactory; + } + } + logger.LogWarning( "No factory found for manifest {ManifestId} (Publisher: {Publisher}, ContentType: {ContentType})", manifest.Id, - manifest.Publisher?.PublisherType ?? GameClientConstants.UnknownVersion, + manifest.Publisher?.PublisherType ?? "unknown", manifest.ContentType); return null; diff --git a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs index b7bbf93a2..3f1b78008 100644 --- a/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs +++ b/GenHub/GenHub/Features/Content/Services/Publishers/SuperHackersProvider.cs @@ -71,57 +71,98 @@ public override async Task>> Se { try { + cancellationToken.ThrowIfCancellationRequested(); var results = new List(); + var errors = new List(); - // Directly fetch latest release from TheSuperHackers/GeneralsGameCode - var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - cancellationToken); + var targets = new (string Owner, string Repo, ContentType ContentType, GameType? TargetGame, string DisplayName)[] + { + (SuperHackersConstants.GeneralsGameCodeOwner, SuperHackersConstants.GeneralsGameCodeRepo, ContentType.GameClient, GameType.Generals, SuperHackersConstants.PublisherName), + (SuperHackersConstants.GeneralsGamePatch2Owner, SuperHackersConstants.GeneralsGamePatch2Repo, ContentType.Patch, null, SuperHackersConstants.GeneralsGamePatch2DisplayName), + }; - if (latestRelease != null && - (string.IsNullOrWhiteSpace(query.AuthorName) || - query.AuthorName.Equals(SuperHackersConstants.GeneralsGameCodeOwner, StringComparison.OrdinalIgnoreCase)) && - (string.IsNullOrWhiteSpace(query.SearchTerm) || - latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || - SuperHackersConstants.GeneralsGameCodeRepo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase))) + var matchingTargets = targets.Where(t => + (!query.ContentType.HasValue || query.ContentType.Value == t.ContentType) && + (!query.TargetGame.HasValue || t.TargetGame == null || query.TargetGame.Value == t.TargetGame.Value) && + (string.IsNullOrWhiteSpace(query.AuthorName) || query.AuthorName.Equals(t.Owner, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(query.GitHubAuthor) || query.GitHubAuthor.Equals(t.Owner, StringComparison.OrdinalIgnoreCase))).ToList(); + + foreach (var (owner, repo, contentType, targetGame, displayName) in matchingTargets) { - // Generate manifest ID - var manifestId = ManifestIdGenerator.GenerateGitHubContentId( - SuperHackersConstants.GeneralsGameCodeOwner, - SuperHackersConstants.GeneralsGameCodeRepo, - ContentType.GameClient, - latestRelease.TagName); - - var result = new ContentSearchResult + try { - Id = manifestId, - Name = latestRelease.Name ?? $"{SuperHackersConstants.PublisherName} {latestRelease.TagName}", - Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", - Version = latestRelease.TagName ?? "latest", - AuthorName = SuperHackersConstants.GeneralsGameCodeOwner, - ContentType = ContentType.GameClient, - TargetGame = GameType.Generals, // Simplification, could infer - IsInferred = false, - ProviderName = SourceName, - RequiresResolution = true, - ResolverId = SuperHackersConstants.ResolverId, - SourceUrl = latestRelease.HtmlUrl, - LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, - ResolverMetadata = + cancellationToken.ThrowIfCancellationRequested(); + + var latestRelease = await gitHubApiClient.GetLatestReleaseAsync( + owner, + repo, + cancellationToken); + + if (latestRelease != null && + (string.IsNullOrWhiteSpace(query.SearchTerm) || + latestRelease.Name?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true || + repo.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + displayName.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) || + latestRelease.Body?.Contains(query.SearchTerm, StringComparison.OrdinalIgnoreCase) == true)) { - [GitHubConstants.OwnerMetadataKey] = SuperHackersConstants.GeneralsGameCodeOwner, - [GitHubConstants.RepoMetadataKey] = SuperHackersConstants.GeneralsGameCodeRepo, - [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", - }, - }; - - result.SetData(latestRelease); - results.Add(result); + var manifestId = ManifestIdGenerator.GenerateGitHubContentId( + owner, + repo, + contentType, + latestRelease.TagName); + + var resolvedTargetGame = targetGame ?? query.TargetGame ?? GameType.Unknown; + + var result = new ContentSearchResult + { + Id = manifestId, + Name = !string.IsNullOrWhiteSpace(latestRelease.Name) ? latestRelease.Name : $"{displayName} {latestRelease.TagName}", + Description = latestRelease.Body ?? "SuperHackers release - details available after resolution", + Version = latestRelease.TagName ?? "latest", + AuthorName = owner, + ContentType = contentType, + TargetGame = resolvedTargetGame, + IsInferred = false, + ProviderName = SourceName, + RequiresResolution = true, + ResolverId = SuperHackersConstants.ResolverId, + SourceUrl = latestRelease.HtmlUrl, + LastUpdated = latestRelease.PublishedAt?.DateTime ?? latestRelease.CreatedAt.DateTime, + ResolverMetadata = + { + [GitHubConstants.OwnerMetadataKey] = owner, + [GitHubConstants.RepoMetadataKey] = repo, + [GitHubConstants.TagMetadataKey] = latestRelease.TagName ?? "latest", + }, + }; + + result.SetData(latestRelease); + results.Add(result); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to fetch SuperHackers release for {Owner}/{Repo}", owner, repo); + errors.Add($"{owner}/{repo}: {ex.Message}"); + } + } + + if (results.Count == 0 && errors.Count > 0) + { + return OperationResult>.CreateFailure( + $"Search failed for SuperHackers targets: {string.Join("; ", errors)}"); } return OperationResult>.CreateSuccess(results); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { Logger.LogError(ex, "Failed to search SuperHackers content"); diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index b62d5aeef..4fddc56f1 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -97,7 +97,7 @@ public async Task> StartProcessAsync(GameLaunch if (process.HasExited) { - return await HandleImmediateProcessExitAsync(process, configuration, capturedErrors); + return HandleImmediateProcessExit(process, configuration, capturedErrors); } } @@ -767,7 +767,7 @@ private ProcessStartInfo ConfigureProcessStartInfo(GameLaunchConfiguration confi return processStartInfo; } - private async Task> HandleImmediateProcessExitAsync( + private OperationResult HandleImmediateProcessExit( Process process, GameLaunchConfiguration configuration, BoundedErrorBuffer capturedErrors) diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs index 2ffca7e4c..7e36d6669 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileLauncherFacade.cs @@ -451,99 +451,25 @@ private async Task> LaunchToolProfileAsyn { logger.LogInformation("[Launch] Detected Tool profile, launching tool directly"); - // Get the tool manifest - if (string.IsNullOrWhiteSpace(profile.ToolContentId)) - { - return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); - } - - if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); - } - - var toolManifestResult = await manifestPool.GetManifestAsync( - toolManifestId, - cancellationToken); - - if (toolManifestResult.Failed || toolManifestResult.Data == null) + var manifestResult = await ResolveToolManifestAsync(profile, cancellationToken); + if (manifestResult.Failed || manifestResult.Data == null) { return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + manifestResult.FirstError ?? ProfileValidationConstants.FailedToLoadToolManifest); } - var toolManifest = toolManifestResult.Data; + var toolManifest = manifestResult.Data; logger.LogDebug("[Launch] Tool manifest loaded: {ManifestId}", toolManifest.Id); - var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); - string toolWorkspacePath = string.Empty; - string? actualWorkspaceId = null; - - if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + var workspaceResult = await ResolveToolWorkspaceAsync(profile, toolManifest, cancellationToken); + if (workspaceResult.Failed) { - toolWorkspacePath = toolDirectory.Data; - logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolWorkspacePath); - } - else - { - logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); - - var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient - { - Name = toolManifest.Name, - GameType = toolManifest.TargetGame, - }; - - var appDataBase = configurationProvider.GetApplicationDataPath(); - if (!Directory.Exists(appDataBase)) - { - Directory.CreateDirectory(appDataBase); - } - - var baseDetails = appDataBase; - - var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); - var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; - - var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); - var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); - - if (effectiveToolStrategy != requestedToolStrategy) - { - logger.LogInformation( - "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", - requestedToolStrategy); - } - - actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; - var workspaceConfig = new WorkspaceConfiguration - { - Id = actualWorkspaceId, - Manifests = [.. allManifests], - GameClient = dummyGameClient, - Strategy = effectiveToolStrategy, - ForceRecreate = false, - ValidateAfterPreparation = true, - BaseInstallationPath = baseDetails, - WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), - SkipCleanup = false, - }; - - var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); - if (prepareResult.Failed) - { - return ProfileOperationResult.CreateFailure( - $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); - } - - toolWorkspacePath = prepareResult.Data!.WorkspacePath; - logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult.CreateFailure( + workspaceResult.FirstError ?? ProfileValidationConstants.FailedToPrepareToolWorkspace); } - var toolDirectoryPath = toolWorkspacePath; - var toolExecutable = toolManifest.Files?.FirstOrDefault(f => f.IsExecutable) - ?? toolManifest.Files?.FirstOrDefault(f => f.RelativePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + var (toolDirectoryPath, actualWorkspaceId) = workspaceResult.Data; + var toolExecutable = ResolveToolExecutable(toolManifest); if (toolExecutable == null) { @@ -564,35 +490,7 @@ private async Task> LaunchToolProfileAsyn try { - var processStartInfo = new ProcessStartInfo - { - FileName = toolExecutablePath, - WorkingDirectory = toolDirectoryPath, - Arguments = profile.CommandLineArguments ?? string.Empty, - UseShellExecute = false, - }; - - if (profile.EnvironmentVariables != null) - { - foreach (var envVar in profile.EnvironmentVariables) - { - processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; - } - } - - Process? process = null; - try - { - process = Process.Start(processStartInfo); - } - catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) - { - logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); - processStartInfo.UseShellExecute = true; - processStartInfo.Verb = "runas"; - process = Process.Start(processStartInfo); - } - + var process = StartToolProcess(toolExecutablePath, toolDirectoryPath, profile); if (process == null) { return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProcessStartFailed); @@ -631,12 +529,181 @@ private async Task> LaunchToolProfileAsyn } catch (Exception ex) { - logger.LogError(ex, "[Launch] Tool launch failed"); + logger.LogError(ex, "[Launch] Unexpected error launching tool for profile {ProfileId}", profileId); notificationService.ShowError( ProfileValidationConstants.ToolLaunchFailedTitle, $"Failed to launch '{profile.Name}': {ex.Message}", NotificationDurations.VeryLong); - return ProfileOperationResult.CreateFailure($"Tool launch failed: {ex.Message}"); + return ProfileOperationResult.CreateFailure( + $"Tool launch failed: {ex.Message}"); + } + } + + private async Task> ResolveToolManifestAsync( + GameProfile profile, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(profile.ToolContentId)) + { + return ProfileOperationResult.CreateFailure(ProfileValidationConstants.ToolProfileMissingContentId); + } + + if (!ManifestId.TryCreate(profile.ToolContentId, out var toolManifestId)) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.InvalidToolContentId}: {profile.ToolContentId}"); + } + + var toolManifestResult = await manifestPool.GetManifestAsync( + toolManifestId, + cancellationToken); + + if (toolManifestResult.Failed || toolManifestResult.Data == null) + { + return ProfileOperationResult.CreateFailure( + $"{ProfileValidationConstants.FailedToLoadToolManifest}: {toolManifestResult.FirstError}"); + } + + return ProfileOperationResult.CreateSuccess(toolManifestResult.Data); + } + + private async Task> ResolveToolWorkspaceAsync( + GameProfile profile, + ContentManifest toolManifest, + CancellationToken cancellationToken) + { + var toolDirectory = await manifestPool.GetContentDirectoryAsync(toolManifest.Id, cancellationToken); + if (toolDirectory.Success && !string.IsNullOrEmpty(toolDirectory.Data)) + { + logger.LogInformation("[Launch] Using existing tool directory: {Path}", toolDirectory.Data); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolDirectory.Data, null)); + } + + logger.LogInformation("[Launch] Tool content requires hydration, using WorkspaceManager"); + + var dummyGameClient = new GenHub.Core.Models.GameClients.GameClient + { + Name = toolManifest.Name, + GameType = toolManifest.TargetGame, + }; + + var appDataBase = configurationProvider.GetApplicationDataPath(); + if (!Directory.Exists(appDataBase)) + { + Directory.CreateDirectory(appDataBase); + } + + var resolutionResult = await dependencyResolver.ResolveDependenciesWithManifestsAsync(profile.EnabledContentIds ?? [], cancellationToken); + var allManifests = resolutionResult.Success ? resolutionResult.ResolvedManifests : [toolManifest]; + + var requestedToolStrategy = profile.WorkspaceStrategy ?? configurationProvider.GetDefaultWorkspaceStrategy(); + var effectiveToolStrategy = ResolveSupportedWorkspaceStrategy(requestedToolStrategy); + + if (effectiveToolStrategy != requestedToolStrategy) + { + logger.LogInformation( + "[Launch] Tool workspace - Switching from {OriginalStrategy} to HardLink: symlinks are unavailable in this environment", + requestedToolStrategy); + } + + var actualWorkspaceId = $"{ProfileConstants.ToolProfileWorkspaceIdPrefix}-{profile.Id}"; + var workspaceConfig = new WorkspaceConfiguration + { + Id = actualWorkspaceId, + Manifests = [.. allManifests], + GameClient = dummyGameClient, + Strategy = effectiveToolStrategy, + ForceRecreate = false, + ValidateAfterPreparation = true, + BaseInstallationPath = appDataBase, + WorkspaceRootPath = Path.Combine(appDataBase, DirectoryNames.ToolWorkspaces), + SkipCleanup = false, + }; + + var prepareResult = await workspaceManager.PrepareWorkspaceAsync(workspaceConfig, progress: null, skipCleanup: false, cancellationToken: cancellationToken); + if (prepareResult.Failed) + { + return ProfileOperationResult<(string, string?)>.CreateFailure( + $"{ProfileValidationConstants.FailedToPrepareToolWorkspace}: {prepareResult.FirstError}"); + } + + var toolWorkspacePath = prepareResult.Data!.WorkspacePath; + logger.LogInformation("[Launch] Tool workspace prepared at: {Path}", toolWorkspacePath); + return ProfileOperationResult<(string, string?)>.CreateSuccess((toolWorkspacePath, actualWorkspaceId)); + } + + private ManifestFile? ResolveToolExecutable(ContentManifest toolManifest) + { + var resolvedFiles = ManifestVariantResolver.ResolveFiles(toolManifest); + var resolution = ManifestVariantResolver.ResolveEntryPoint(toolManifest); + + if (resolution.Success && resolution.RelativePath != null) + { + var toolExecutable = resolvedFiles?.FirstOrDefault(f => + ManifestVariantResolver.PathsMatch(f.RelativePath, resolution.RelativePath)); + + if (toolExecutable != null) + { + logger.LogInformation( + "[Launch] Tool executable resolved for manifest {ManifestId}: {RelativePath} ({Reason})", + toolManifest.Id, + toolExecutable.RelativePath, + resolution.Reason); + } + else + { + logger.LogWarning( + "[Launch] Entry point '{RelativePath}' resolved for tool manifest {ManifestId} ({Reason}) but not found in resolved files", + resolution.RelativePath, + toolManifest.Id, + resolution.Reason); + } + + return toolExecutable; + } + + logger.LogWarning( + "[Launch] Entry point resolution for tool manifest '{ManifestId}' did not succeed: {Resolution}", + toolManifest.Id, + resolution); + + return null; + } + + private Process? StartToolProcess(string toolExecutablePath, string toolDirectoryPath, GameProfile profile) + { + var processStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = false, + }; + + if (profile.EnvironmentVariables != null) + { + foreach (var envVar in profile.EnvironmentVariables) + { + processStartInfo.EnvironmentVariables[envVar.Key] = envVar.Value; + } + } + + try + { + return Process.Start(processStartInfo); + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 740) + { + logger.LogWarning("Tool requires elevation (Error 740). Retrying with UseShellExecute=true and Verb='runas'. Environment variables will be ignored."); + var elevatedStartInfo = new ProcessStartInfo + { + FileName = toolExecutablePath, + WorkingDirectory = toolDirectoryPath, + Arguments = profile.CommandLineArguments ?? string.Empty, + UseShellExecute = true, + Verb = "runas", + }; + return Process.Start(elevatedStartInfo); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs index 55a28b999..386f01d9b 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/AddLocalContentViewModel.cs @@ -12,6 +12,8 @@ using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Content; using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Utilities; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -29,7 +31,7 @@ public partial class AddLocalContentViewModel( IContentStorageService? contentStorageService, IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, - ILogger? logger = null) : ObservableObject + ILogger? logger = null) : ObservableObject, IDisposable { /// /// Gets the list of available game types. @@ -56,6 +58,26 @@ public partial class AddLocalContentViewModel( ContentType.Mission, ]; + /// + /// Counts the total number of executables in the given file tree items recursively. + /// + /// The file tree items to inspect. + /// The total number of executable files found. + internal static int CountExecutables(IEnumerable items) + { + int count = 0; + foreach (var item in items) + { + if (item.IsExecutable) count++; + count += CountExecutables(item.Children); + } + + return count; + } + + private static bool RequiresExecutable(ContentType contentType) => + contentType is ContentType.GameClient or ContentType.ModdingTool or ContentType.Executable; + private static FileTreeItem? FindFirstExecutable(IEnumerable items) { foreach (var item in items) @@ -75,21 +97,10 @@ public partial class AddLocalContentViewModel( return null; } - private static int CountExecutables(IEnumerable items) - { - int count = 0; - foreach (var item in items) - { - if (item.IsExecutable) count++; - count += CountExecutables(item.Children); - } - - return count; - } - private readonly string _stagingPath = Path.Combine(Path.GetTempPath(), "GenHub_Staging_" + Guid.NewGuid()); private string? _originalManifestId; + private string? _pendingEntryPoint; /// /// Gets a value indicating whether we are editing existing content. @@ -177,7 +188,7 @@ private static int CountExecutables(IEnumerable items) private bool _isDemoMode; /// - /// Gets or sets the selected executable item (for Executable/ModdingTool content type). + /// Gets or sets the selected executable item (for GameClient/Executable/ModdingTool content type). /// [ObservableProperty] private FileTreeItem? _selectedExecutableItem; @@ -192,7 +203,7 @@ private static int CountExecutables(IEnumerable items) /// /// Gets a value indicating whether the executable selection should be shown. /// - public bool ShowExecutableSelection => (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) && ExecutableCount > 1; + public bool ShowExecutableSelection => RequiresExecutable(SelectedContentType) && ExecutableCount > 0; /// /// Gets the text to display in the preview area when no content is loaded. @@ -255,6 +266,7 @@ public async Task LoadFromManifestAsync(ContentDisplayItem item) StatusMessage = "Loading existing content..."; _originalManifestId = item.ManifestId.Value; + _pendingEntryPoint = item.Manifest?.EntryPoint; ContentName = item.DisplayName ?? string.Empty; SelectedContentType = item.ContentType; SelectedGameType = item.GameType; @@ -487,24 +499,81 @@ public async Task ImportContentAsync(string path) } } + /// + public void Dispose() + { + _cts?.Dispose(); + _cts = null; + CleanupStaging(); + GC.SuppressFinalize(this); + } + private static List BuildDirectoryTree(DirectoryInfo dir) + => BuildDirectoryTree(dir, CollectExecutableDirectories(dir)); + + private static HashSet CollectExecutableDirectories(DirectoryInfo root) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var file in root.EnumerateFiles("*", SearchOption.AllDirectories)) + { + if (!ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(file.Name) + && !file.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + for (var d = file.Directory; d != null; d = d.Parent) + { + if (!result.Add(d.FullName)) + { + break; + } + } + } + } + catch + { + // ignore inaccessible directories + } + + return result; + } + + private static List BuildDirectoryTree(DirectoryInfo dir, HashSet executableDirs) { var items = new List(); - if (!dir.Exists) return items; + if (!dir.Exists) + { + return items; + } + + var subDirs = dir.GetDirectories(); + var prioritizedDirs = subDirs + .OrderByDescending(d => executableDirs.Contains(d.FullName)) + .ThenBy(d => d.Name) + .Take(20); - foreach (var d in dir.GetDirectories().Take(20)) + foreach (var d in prioritizedDirs) { items.Add(new FileTreeItem { Name = d.Name, IsFile = false, FullPath = d.FullName, - Children = new ObservableCollection(BuildDirectoryTree(d)), + Children = new ObservableCollection(BuildDirectoryTree(d, executableDirs)), }); } - foreach (var f in dir.GetFiles().Take(50)) + var files = dir.GetFiles(); + var prioritizedFiles = files + .OrderByDescending(f => ExecutableFileClassifier.IsLegacyLaunchCandidateFromName(f.Name) || f.Extension.Equals(".exe", StringComparison.OrdinalIgnoreCase)) + .ThenBy(f => f.Name) + .Take(50); + + foreach (var f in prioritizedFiles) { items.Add(new FileTreeItem { Name = f.Name, IsFile = true, FullPath = f.FullName }); } @@ -640,6 +709,20 @@ private async Task AddContentAsync() _cts = new CancellationTokenSource(); + string? entryPoint = null; + if (RequiresExecutable(SelectedContentType) && SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + entryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to determine relative path for selected executable '{FullPath}'. Falling back to file name '{Name}'", SelectedExecutableItem.FullPath, SelectedExecutableItem.Name); + entryPoint = SelectedExecutableItem.Name; + } + } + // Preserve SourcePath metadata if available // Note: We no longer write to "source.path" file to avoid polluting the content. // Instead we pass the SourcePath directly to the service. @@ -652,7 +735,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token) + _cts.Token, + entryPoint) : await localContentService.CreateLocalContentManifestAsync( _stagingPath, ContentName, @@ -660,7 +744,8 @@ private async Task AddContentAsync() targetGame, SourcePath, progress, - _cts.Token); + _cts.Token, + entryPoint); if (result.Success) { @@ -770,6 +855,29 @@ private void CreateMapFoldersIfNeeded() } } + private FileTreeItem? FindFileItemByRelativePath(IEnumerable items, string relativePath) + { + var normalizedTarget = relativePath.Replace('\\', '/').TrimStart('/'); + foreach (var item in items) + { + if (item.IsFile) + { + var itemRel = Path.GetRelativePath(_stagingPath, item.FullPath).Replace('\\', '/').TrimStart('/'); + if (ManifestVariantResolver.PathsMatch(itemRel, normalizedTarget)) + { + return item; + } + } + else + { + var found = FindFileItemByRelativePath(item.Children, relativePath); + if (found != null) return found; + } + } + + return null; + } + private async Task RefreshStagingTreeAsync() { bool wasBusy = IsBusy; @@ -777,6 +885,23 @@ private async Task RefreshStagingTreeAsync() { if (!wasBusy) IsBusy = true; + string? previousRelativePath = null; + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + previousRelativePath = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + else if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + previousRelativePath = _pendingEntryPoint; + } + FileTree.Clear(); SelectedExecutableItem = null; // Clear previous selection on refresh if (Directory.Exists(_stagingPath)) @@ -791,10 +916,29 @@ private async Task RefreshStagingTreeAsync() ExecutableCount = CountExecutables(FileTree); - // Auto-select first executable if content type requires it - if (SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable) + // Reselect previously selected executable or auto-select first if content type requires it + if (RequiresExecutable(SelectedContentType)) { - AutoSelectFirstExecutable(); + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(previousRelativePath)) + { + matchedItem = FindFileItemByRelativePath(FileTree, previousRelativePath); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + _pendingEntryPoint = null; + AutoSelectFirstExecutable(); + } + } + else + { + SelectedExecutableItem = null; } Validate(); @@ -816,8 +960,8 @@ private void Validate() var stagingExists = Directory.Exists(_stagingPath); var stagingHasEntries = stagingExists && Directory.EnumerateFileSystemEntries(_stagingPath).Any(); - // For ModdingTool (Tool) and Executable, we also need an executable selected - var requiresExecutable = SelectedContentType == ContentType.ModdingTool || SelectedContentType == ContentType.Executable; + // For GameClient, ModdingTool (Tool), and Executable, we also need an executable selected + var requiresExecutable = RequiresExecutable(SelectedContentType); var hasExecutableIfNeeded = !requiresExecutable || SelectedExecutableItem != null; CanAdd = hasName && (hasFiles || stagingHasEntries) && hasExecutableIfNeeded; @@ -842,10 +986,44 @@ partial void OnSelectedContentTypeChanged(ContentType value) OnPropertyChanged(nameof(ShowExecutableSelection)); OnPropertyChanged(nameof(PreviewIdleText)); - // Auto-select first executable if switching to ModdingTool or Executable - if ((value == ContentType.ModdingTool || value == ContentType.Executable) && SelectedExecutableItem == null) + // Auto-select first executable if switching to a content type that requires it, + // or clear selection when switching to a non-executable content type + if (RequiresExecutable(value)) + { + if (SelectedExecutableItem == null) + { + FileTreeItem? matchedItem = null; + if (!string.IsNullOrWhiteSpace(_pendingEntryPoint)) + { + matchedItem = FindFileItemByRelativePath(FileTree, _pendingEntryPoint); + } + + if (matchedItem != null && matchedItem.IsExecutable) + { + SelectedExecutableItem = matchedItem; + _pendingEntryPoint = null; + } + else + { + AutoSelectFirstExecutable(); + } + } + } + else { - AutoSelectFirstExecutable(); + if (SelectedExecutableItem != null && !string.IsNullOrWhiteSpace(SelectedExecutableItem.FullPath)) + { + try + { + _pendingEntryPoint = Path.GetRelativePath(_stagingPath, SelectedExecutableItem.FullPath).Replace('\\', '/'); + } + catch + { + // Ignore path calculation error + } + } + + SelectedExecutableItem = null; } Validate(); diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs index afbd8961b..a22b52d3d 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/DemoAddLocalContentViewModel.cs @@ -147,6 +147,7 @@ private void InitializeDemoData() }; FileTree.Add(modFolder); + ExecutableCount = CountExecutables(FileTree); // Set status message StatusMessage = "Demo content ready. Click buttons to see what they do!"; diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs index 20b12e965..81052c517 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/FileTreeItem.cs @@ -14,17 +14,23 @@ public partial class FileTreeItem : ObservableObject /// /// Gets or sets the name of the file or directory. /// - public string Name { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _name = string.Empty; /// /// Gets or sets a value indicating whether this item is a file. /// - public bool IsFile { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private bool _isFile; /// /// Gets or sets the full path of the file or directory. /// - public string FullPath { get; set; } = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsExecutable))] + private string _fullPath = string.Empty; /// /// Gets or sets the children of this item (for directories). diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index ae01bb830..fd7e36b13 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -699,7 +699,7 @@ private async Task AddLocalContentAsync(Avalonia.Controls.Window? owner) if (dialogOwner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, @@ -767,7 +767,7 @@ private async Task EditContentAsync(ContentDisplayItem? contentItem) if (owner == null) return; - var vm = new AddLocalContentViewModel( + using var vm = new AddLocalContentViewModel( _localContentService, _contentStorageService, _genLauncherNormalizationService, diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 081789f27..d9256dfae 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -13,6 +13,7 @@ + @@ -115,7 +116,13 @@ + Classes="glass"> + + + + + + @@ -157,7 +164,7 @@ - +