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/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.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 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;
}
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/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
}
}