From d1a5fc8f19153743396816b933c13c485c96e8dd Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 07:16:10 +0200 Subject: [PATCH 1/5] feat(windows): add genhub URI protocol scheme registrar and CLI parser --- .../Constants/CommandLineConstants.cs | 15 +- GenHub/GenHub.Core/Constants/IpcCommands.cs | 3 +- .../GenHub.Core/Helpers/CommandLineParser.cs | 21 +-- .../Helpers/CommandLineParserTests.cs | 139 ++++++++++++++++++ .../Shortcuts/UriSchemeRegistrarTests.cs | 56 +++++++ .../Features/Shortcuts/UriSchemeRegistrar.cs | 75 ++++++++++ GenHub/GenHub.Windows/Program.cs | 8 +- 7 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs create mode 100644 GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index 4b0821443..b790e4190 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,22 @@ public static class CommandLineConstants public const string LaunchProfileInlinePrefix = "--launch-profile="; /// - /// URI scheme used for protocol handling. + /// Custom URI scheme registered so OS/browser links can open GenHub. /// public const string UriScheme = "genhub://"; /// - /// Command for subscribing to a catalog via URI. + /// URI path segment for content subscription (genhub://subscribe?url=...). /// public const string SubscribeCommand = "subscribe"; /// - /// Full prefix for subscription URI. + /// Full prefix for subscription URIs (genhub://subscribe). /// public const string SubscribeUriPrefix = UriScheme + SubscribeCommand; /// - /// Query parameter name for the catalog URL in a subscription URI. + /// Query parameter carrying the absolute URL of a catalog (or future provider definition). /// public const string SubscribeUrlParam = "?url="; } diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs index 1a66630f8..4096fd317 100644 --- a/GenHub/GenHub.Core/Constants/IpcCommands.cs +++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs @@ -11,7 +11,8 @@ public static class IpcCommands public const string LaunchProfilePrefix = "launch-profile:"; /// - /// Command prefix used to subscribe to a catalog via IPC. + /// Command prefix used to forward a subscribe URL to the primary instance + /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=.... /// public const string SubscribePrefix = "subscribe:"; } diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index f6b570af0..004c1bf11 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,22 +34,25 @@ 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); + int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase); if (queryStart != -1) { - var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; + string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; return Uri.UnescapeDataString(url).Trim('"'); } } 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..76947969d --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs @@ -0,0 +1,139 @@ +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); + } +} 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..20eb04259 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Runtime.Versioning; +using GenHub.Windows.Features.Shortcuts; +using Microsoft.Win32; +using Xunit; + +namespace GenHub.Tests.Windows.Features.Shortcuts; + +/// +/// Unit tests for . +/// +[SupportedOSPlatform("windows")] +public sealed class UriSchemeRegistrarTests +{ + /// + /// 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(@"Software\Classes\genhub"); + 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(@"Software\Classes\genhub\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); + } +} diff --git a/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs new file mode 100644 index 000000000..cb5510194 --- /dev/null +++ b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +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 ClassesSubKey = @"Software\Classes\" + SchemeName; + private const string SchemeName = "genhub"; + + /// + /// 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 + { + 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, $"URL:{SchemeName} protocol"); + classesKey.SetValue("URL Protocol", string.Empty); + + using var iconKey = classesKey.CreateSubKey("DefaultIcon"); + iconKey.SetValue(string.Empty, $"{executablePath},0"); + + using var commandKey = classesKey.CreateSubKey(@"shell\open\command"); + var desiredCommand = $"\"{executablePath}\" \"%1\""; + + // Idempotent: skip the write (and the UAC/notify churn) when already correct. + if (commandKey.GetValue(string.Empty) is string existing && + string.Equals(existing, desiredCommand, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + 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..c34ccf598 100644 --- a/GenHub/GenHub.Windows/Program.cs +++ b/GenHub/GenHub.Windows/Program.cs @@ -49,10 +49,14 @@ public static void Main(string[] args) using var bootstrapLoggerFactory = LoggingModule.CreateBootstrapLoggerFactory(); var bootstrapLogger = bootstrapLoggerFactory.CreateLogger(); + // Register the genhub:// URI scheme with Windows so clicked links open this executable. + // Idempotent and per-user (HKCU), so safe on every launch. + Features.Shortcuts.UriSchemeRegistrar.Register(bootstrapLogger); + // 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 +78,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); From e3fb30aaa813ea6ef5b9dbd6cbce564e6b188149 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 08:22:36 +0200 Subject: [PATCH 2/5] fix(windows): handle subscription URL dispatch in App and isolate registry tests --- .../Shortcuts/UriSchemeRegistrarTests.cs | 103 +++++++++++++++++- GenHub/GenHub/App.axaml.cs | 72 +++++++++++- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs index 20eb04259..e09097590 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Runtime.Versioning; using GenHub.Windows.Features.Shortcuts; using Microsoft.Win32; @@ -10,8 +12,26 @@ namespace GenHub.Tests.Windows.Features.Shortcuts; /// Unit tests for . /// [SupportedOSPlatform("windows")] -public sealed class UriSchemeRegistrarTests +public sealed class UriSchemeRegistrarTests : IDisposable { + private const string TargetKeyPath = @"Software\Classes\genhub"; + private readonly RegistryKeySnapshot? _snapshot; + private readonly bool _existedPrior; + + /// + /// Initializes a new instance of the class. + /// Captures a snapshot of any pre-existing registry state to restore during teardown. + /// + public UriSchemeRegistrarTests() + { + using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); + _existedPrior = rootKey != null; + if (rootKey != null) + { + _snapshot = CaptureSnapshot(rootKey); + } + } + /// /// Verifies that Register creates or updates the genhub registry keys in HKCU. /// @@ -22,7 +42,7 @@ public void Register_CreatesOrUpdatesGenhubRegistryKey() UriSchemeRegistrar.Register(); // Assert - using var key = Registry.CurrentUser.OpenSubKey(@"Software\Classes\genhub"); + using var key = Registry.CurrentUser.OpenSubKey(TargetKeyPath); Assert.NotNull(key); var protocolValue = key.GetValue(string.Empty) as string; @@ -31,7 +51,7 @@ public void Register_CreatesOrUpdatesGenhubRegistryKey() var urlProtocolFlag = key.GetValue("URL Protocol"); Assert.NotNull(urlProtocolFlag); - using var commandKey = Registry.CurrentUser.OpenSubKey(@"Software\Classes\genhub\shell\open\command"); + using var commandKey = Registry.CurrentUser.OpenSubKey($@"{TargetKeyPath}\shell\open\command"); Assert.NotNull(commandKey); var command = commandKey.GetValue(string.Empty) as string; @@ -53,4 +73,81 @@ public void Register_IsIdempotent() // Assert Assert.Null(ex); } + + /// + public void Dispose() + { + try + { + Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false); + + if (_existedPrior && _snapshot != null) + { + using var rootKey = Registry.CurrentUser.CreateSubKey(TargetKeyPath, writable: true); + if (rootKey != null) + { + RestoreSnapshot(rootKey, _snapshot); + } + } + } + catch + { + // Suppress cleanup exceptions in tests to avoid masking assertion results + } + } + + 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) + { + foreach (var (valueName, (value, kind)) in snapshot.Values) + { + if (value != null) + { + targetKey.SetValue(valueName, value, kind); + } + } + + 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/App.axaml.cs b/GenHub/GenHub/App.axaml.cs index 3017451f0..3addda420 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; @@ -66,7 +68,10 @@ public override void OnFrameworkInitializationCompleted() SubscribeToSingleInstanceCommands(mainWindow); // Handle launch profile from startup args (first launch with shortcut) - SafeFireAndForget(HandleLaunchProfileArgsAsync(desktop.Args, mainWindow), "HandleLaunchProfileArgsAsync"); + SafeFireAndForget(HandleLaunchProfileArgsAsync(desktop.Args, mainWindow), nameof(HandleLaunchProfileArgsAsync)); + + // Handle subscription URL from startup args (first launch with genhub://subscribe) + SafeFireAndForget(HandleSubscriptionArgsAsync(desktop.Args, mainWindow), nameof(HandleSubscriptionArgsAsync)); } base.OnFrameworkInitializationCompleted(); @@ -187,6 +192,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) @@ -216,7 +240,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 +301,40 @@ 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 + { + logger?.LogInformation("Handling subscription URL: {Url}", subscriptionUrl); + + if (mainWindow?.DataContext is MainViewModel mainViewModel) + { + mainViewModel.SelectTab(NavigationTab.Downloads); + } + + var dialogService = _serviceProvider.GetService(); + if (dialogService != null) + { + var confirmed = await dialogService.ShowConfirmationAsync( + "Subscribe to Catalog", + $"Do you want to subscribe to content from:\n{subscriptionUrl}", + "Subscribe", + "Cancel"); + + if (confirmed) + { + logger?.LogInformation("User confirmed subscription to: {Url}", subscriptionUrl); + var notificationService = _serviceProvider.GetService(); + notificationService?.ShowSuccess("Subscribed", $"Successfully subscribed to: {subscriptionUrl}"); + } + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Exception while handling subscription URL {Url}", subscriptionUrl); + } + } } From cd0c89645cd18a1024d078d315a46b33d7752228 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 10:21:51 +0200 Subject: [PATCH 3/5] fix(review): address review feedback, simplify single instance event lambda, and harden URI parsing --- .../Constants/CommandLineConstants.cs | 7 +- .../GenHub.Core/Helpers/CommandLineParser.cs | 18 +++- .../Helpers/CommandLineParserTests.cs | 82 +++++++++++++++++++ .../Shortcuts/UriSchemeRegistrarTests.cs | 67 ++++++++++----- .../Shortcuts/WindowsRegistryCollection.cs | 15 ++++ .../Features/Shortcuts/UriSchemeRegistrar.cs | 42 +++++++--- GenHub/GenHub.Windows/Program.cs | 8 +- GenHub/GenHub/App.axaml.cs | 43 ++++++---- .../CommunityOutpostDeliverer.cs | 2 +- .../Services/GitHub/GitHubContentDeliverer.cs | 2 +- global.json | 2 +- 11 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/WindowsRegistryCollection.cs diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs index b790e4190..30cd69c4f 100644 --- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs @@ -20,10 +20,15 @@ public static class CommandLineConstants /// public const string LaunchProfileInlinePrefix = "--launch-profile="; + /// + /// Scheme name for custom protocol registration. + /// + public const string SchemeName = "genhub"; + /// /// Custom URI scheme registered so OS/browser links can open GenHub. /// - public const string UriScheme = "genhub://"; + public const string UriScheme = SchemeName + "://"; /// /// URI path segment for content subscription (genhub://subscribe?url=...). diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index 004c1bf11..2cdcf01e5 100644 --- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs +++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs @@ -53,7 +53,23 @@ public static class CommandLineParser if (queryStart != -1) { string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..]; - return Uri.UnescapeDataString(url).Trim('"'); + string unescaped = Uri.UnescapeDataString(url) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Trim('"', '\'', ' ', '\t'); + + if (string.IsNullOrWhiteSpace(unescaped)) + { + return null; + } + + if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return unescaped; + } + + return null; } } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs index 76947969d..b0d459a2b 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs @@ -136,4 +136,86 @@ public void ExtractSubscriptionUrl_CaseInsensitivePrefix_ReturnsUrl() 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); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs index e09097590..529be0d13 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Windows/Features/Shortcuts/UriSchemeRegistrarTests.cs @@ -1,36 +1,26 @@ 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 : IDisposable +public sealed class UriSchemeRegistrarTests(ITestOutputHelper testOutputHelper) : IDisposable { private const string TargetKeyPath = @"Software\Classes\genhub"; - private readonly RegistryKeySnapshot? _snapshot; - private readonly bool _existedPrior; - - /// - /// Initializes a new instance of the class. - /// Captures a snapshot of any pre-existing registry state to restore during teardown. - /// - public UriSchemeRegistrarTests() - { - using var rootKey = Registry.CurrentUser.OpenSubKey(TargetKeyPath, writable: false); - _existedPrior = rootKey != null; - if (rootKey != null) - { - _snapshot = CaptureSnapshot(rootKey); - } - } + private readonly RegistryKeySnapshot? _snapshot = CaptureInitialSnapshot(); + private readonly bool _existedPrior = KeyExists(); /// /// Verifies that Register creates or updates the genhub registry keys in HKCU. @@ -79,8 +69,6 @@ public void Dispose() { try { - Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false); - if (_existedPrior && _snapshot != null) { using var rootKey = Registry.CurrentUser.CreateSubKey(TargetKeyPath, writable: true); @@ -89,13 +77,29 @@ public void Dispose() RestoreSnapshot(rootKey, _snapshot); } } + else + { + Registry.CurrentUser.DeleteSubKeyTree(TargetKeyPath, throwOnMissingSubKey: false); + } } - catch + catch (Exception ex) { - // Suppress cleanup exceptions in tests to avoid masking assertion results + 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 @@ -124,6 +128,16 @@ private static RegistryKeySnapshot CaptureSnapshot(RegistryKey key) 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) @@ -132,6 +146,17 @@ private static void RestoreSnapshot(RegistryKey targetKey, RegistryKeySnapshot s } } + // 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); 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 index cb5510194..1917cf585 100644 --- a/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs +++ b/GenHub/GenHub.Windows/Features/Shortcuts/UriSchemeRegistrar.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using GenHub.Core.Constants; using Microsoft.Extensions.Logging; using Microsoft.Win32; @@ -24,8 +25,8 @@ namespace GenHub.Windows.Features.Shortcuts; /// public static class UriSchemeRegistrar { + private const string SchemeName = CommandLineConstants.SchemeName; private const string ClassesSubKey = @"Software\Classes\" + SchemeName; - private const string SchemeName = "genhub"; /// /// Registers the genhub:// scheme for the current user, pointing at the running @@ -43,26 +44,43 @@ public static void Register(ILogger? logger = null) 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, $"URL:{SchemeName} protocol"); + classesKey.SetValue(string.Empty, desiredProtocol); classesKey.SetValue("URL Protocol", string.Empty); using var iconKey = classesKey.CreateSubKey("DefaultIcon"); - iconKey.SetValue(string.Empty, $"{executablePath},0"); + iconKey.SetValue(string.Empty, desiredIcon); using var commandKey = classesKey.CreateSubKey(@"shell\open\command"); - var desiredCommand = $"\"{executablePath}\" \"%1\""; - - // Idempotent: skip the write (and the UAC/notify churn) when already correct. - if (commandKey.GetValue(string.Empty) is string existing && - string.Equals(existing, desiredCommand, StringComparison.OrdinalIgnoreCase)) - { - return; - } - commandKey.SetValue(string.Empty, desiredCommand); + logger?.LogInformation("Registered genhub:// scheme -> {ExecutablePath}", executablePath); } catch (Exception ex) diff --git a/GenHub/GenHub.Windows/Program.cs b/GenHub/GenHub.Windows/Program.cs index c34ccf598..996834a0d 100644 --- a/GenHub/GenHub.Windows/Program.cs +++ b/GenHub/GenHub.Windows/Program.cs @@ -49,10 +49,6 @@ public static void Main(string[] args) using var bootstrapLoggerFactory = LoggingModule.CreateBootstrapLoggerFactory(); var bootstrapLogger = bootstrapLoggerFactory.CreateLogger(); - // Register the genhub:// URI scheme with Windows so clicked links open this executable. - // Idempotent and per-user (HKCU), so safe on every launch. - Features.Shortcuts.UriSchemeRegistrar.Register(bootstrapLogger); - // Extract profile ID from args if present (for IPC forwarding) var profileId = CommandLineParser.ExtractProfileId(args); @@ -98,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 3addda420..a2f92fc64 100644 --- a/GenHub/GenHub/App.axaml.cs +++ b/GenHub/GenHub/App.axaml.cs @@ -67,11 +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), nameof(HandleLaunchProfileArgsAsync)); - - // Handle subscription URL from startup args (first launch with genhub://subscribe) - SafeFireAndForget(HandleSubscriptionArgsAsync(desktop.Args, mainWindow), nameof(HandleSubscriptionArgsAsync)); + // Handle startup arguments sequentially (launch profile, then subscription if present) + SafeFireAndForget(HandleStartupArgsAsync(desktop.Args, mainWindow), nameof(HandleStartupArgsAsync)); } base.OnFrameworkInitializationCompleted(); @@ -173,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) @@ -221,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"); @@ -308,27 +313,35 @@ private async Task HandleSubscriptionUrlAsync(string subscriptionUrl, MainWindow try { - logger?.LogInformation("Handling subscription URL: {Url}", subscriptionUrl); - - if (mainWindow?.DataContext is MainViewModel mainViewModel) + 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)) { - mainViewModel.SelectTab(NavigationTab.Downloads); + 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{subscriptionUrl}", + $"Do you want to subscribe to content from:\n{uri.AbsoluteUri}", "Subscribe", "Cancel"); if (confirmed) { - logger?.LogInformation("User confirmed subscription to: {Url}", subscriptionUrl); + 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: {subscriptionUrl}"); + notificationService?.ShowSuccess("Subscribed", $"Successfully subscribed to: {uri.AbsoluteUri}"); } } } diff --git a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs index be8788503..9bc5bd496 100644 --- a/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/CommunityOutpost/CommunityOutpostDeliverer.cs @@ -97,7 +97,7 @@ await Task.Run( throw new FileNotFoundException($"Archive file not found or empty: {archivePath}"); } - using var archive = ArchiveFactory.Open(archivePath); + using var archive = ArchiveFactory.Open(fileInfo); foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs index 1814c5362..5d8578b60 100644 --- a/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs +++ b/GenHub/GenHub/Features/Content/Services/GitHub/GitHubContentDeliverer.cs @@ -381,7 +381,7 @@ private async Task ExtractArchiveAsync( await Task.Run( () => { - using var archive = ArchiveFactory.Open(archiveFile); + using var archive = ArchiveFactory.Open(new FileInfo(archiveFile)); int totalEntries = archive.Entries.Count(e => !e.IsDirectory); int currentEntry = 0; diff --git a/global.json b/global.json index 1834d84d1..da333ae07 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "version": "8.0.424", - "rollForward": "latestFeature", + "rollForward": "latestMajor", "allowPrerelease": false } } From bd7d52a8804f6a4a06a5d95e6b11c8c08b3db16c Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 10:40:40 +0200 Subject: [PATCH 4/5] fix(shortcuts): require exact subscribe URI command delimiter before parsing url param --- GenHub/GenHub.Core/Helpers/CommandLineParser.cs | 6 ++++++ .../Helpers/CommandLineParserTests.cs | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs index 2cdcf01e5..d5c595d36 100644 --- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs +++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs @@ -49,6 +49,12 @@ public static class CommandLineParser { if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, 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) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs index b0d459a2b..d9d9fa997 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/CommandLineParserTests.cs @@ -218,4 +218,17 @@ public void ExtractSubscriptionUrl_WithNewlinesAndControlChars_ReturnsSanitizedU 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); + } } From 78f9a404c8d94acc404b1f9bb2f9a7dfe2c664d9 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 11:23:54 +0200 Subject: [PATCH 5/5] test(core): simplify nullable check expression in ContentReconciliationServiceTests --- .../Integration/ContentReconciliationServiceTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs index b203ee30f..6de3d7e9a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Integration/ContentReconciliationServiceTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -81,8 +80,6 @@ public ContentReconciliationServiceTests() /// /// A representing the asynchronous unit test. [Fact] - [SuppressMessage("DeepSource", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")] - [SuppressMessage("csharp", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")] public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToPool_AndUpdateProfilesAsync() { // Arrange @@ -129,7 +126,7 @@ public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToP _profileManagerMock.Verify( x => x.UpdateProfileAsync( "profile-1", - It.Is(r => r.GameClient != null && r.GameClient.Id == newId), + It.Is(r => MatchesGameClientId(r, newId)), It.IsAny()), Times.Once, "Should update profile with new manifest ID"); @@ -283,4 +280,7 @@ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailureAsyn result.FirstError.Should().Be( GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage); } + + private static bool MatchesGameClientId(UpdateProfileRequest request, string expectedId) => + request.GameClient?.Id == expectedId; }