diff --git a/NexusMods.App.sln.DotSettings b/NexusMods.App.sln.DotSettings index d5f1baf00b..de3541f942 100644 --- a/NexusMods.App.sln.DotSettings +++ b/NexusMods.App.sln.DotSettings @@ -41,6 +41,7 @@ EA GOG JWT + MIME VM LG MD diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/HandlerRegistration.cs b/src/Networking/NexusMods.Networking.NexusWebApi/HandlerRegistration.cs new file mode 100644 index 0000000000..10053e5953 --- /dev/null +++ b/src/Networking/NexusMods.Networking.NexusWebApi/HandlerRegistration.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NexusMods.CrossPlatform.ProtocolRegistration; + +namespace NexusMods.Networking.NexusWebApi; + +internal class HandlerRegistration : IHostedService +{ + private readonly ILogger _logger; + private readonly IProtocolRegistration _protocolRegistration; + + public HandlerRegistration( + ILogger logger, + IProtocolRegistration protocolRegistration) + { + _logger = logger; + _protocolRegistration = protocolRegistration; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _ = Task.Run(async () => + { + try + { + await _protocolRegistration.RegisterHandler(uriScheme: "nxm", cancellationToken: cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Exception while registering handler for nxm links"); + } + }, cancellationToken); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/LoginManager.cs b/src/Networking/NexusMods.Networking.NexusWebApi/LoginManager.cs index edfb0b3aaf..1a46b94814 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/LoginManager.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/LoginManager.cs @@ -110,9 +110,6 @@ public LoginManager( /// public async Task LoginAsync(CancellationToken token = default) { - // temporary but if we want oauth to work we _have_ to be registered as the nxm handler - await _protocolRegistration.RegisterSelf("nxm"); - JwtTokenReply? jwtToken; try { @@ -137,7 +134,7 @@ public async Task LoginAsync(CancellationToken token = default) using var tx = _conn.BeginTransaction(); - var newTokenEntity = JWTToken.Create(_conn.Db, tx, jwtToken!); + var newTokenEntity = JWTToken.Create(_conn.Db, tx, jwtToken); if (newTokenEntity is null) { _logger.LogError("Invalid new token data"); diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusMods.Networking.NexusWebApi.csproj b/src/Networking/NexusMods.Networking.NexusWebApi/NexusMods.Networking.NexusWebApi.csproj index 14e7f9dc20..50ea30d993 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusMods.Networking.NexusWebApi.csproj +++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusMods.Networking.NexusWebApi.csproj @@ -19,8 +19,4 @@ - - - - diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/Services.cs b/src/Networking/NexusMods.Networking.NexusWebApi/Services.cs index 66d3b04646..602dd01ef8 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/Services.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/Services.cs @@ -46,6 +46,7 @@ public static IServiceCollection AddNexusWebApi(this IServiceCollection collecti return collection .AddAllSingleton() .AddAllSingleton() + .AddHostedService() .AddNexusApiVerbs(); } } diff --git a/src/NexusMods.App.Cli/ProtocolVerbs.cs b/src/NexusMods.App.Cli/ProtocolVerbs.cs index c80fc82ce2..98fad4ff4d 100644 --- a/src/NexusMods.App.Cli/ProtocolVerbs.cs +++ b/src/NexusMods.App.Cli/ProtocolVerbs.cs @@ -30,13 +30,12 @@ public static IServiceCollection AddProtocolVerbs(this IServiceCollection servic [Verb("associate-nxm", "Associate the nxm:// protocol with this application")] - private static Task AssociateNxm([Injected] IProtocolRegistration protocolRegistration) + private static async Task AssociateNxm([Injected] IProtocolRegistration protocolRegistration) { - protocolRegistration.RegisterSelf("nxm"); - return Task.FromResult(0); + await protocolRegistration.RegisterHandler("nxm"); + return 0; } - [Verb("download-and-install-mod", "Download a mod and install it in one step")] private static async Task DownloadAndInstallMod([Injected] IRenderer renderer, [Option("u","url", "The url of the mod to download")] Uri uri, diff --git a/src/NexusMods.CrossPlatform/NexusMods.CrossPlatform.csproj b/src/NexusMods.CrossPlatform/NexusMods.CrossPlatform.csproj index 1c129ad085..9e01986953 100644 --- a/src/NexusMods.CrossPlatform/NexusMods.CrossPlatform.csproj +++ b/src/NexusMods.CrossPlatform/NexusMods.CrossPlatform.csproj @@ -12,6 +12,10 @@ + + + + IOSInterop.cs diff --git a/src/NexusMods.CrossPlatform/ProtocolRegistration/IProtocolRegistration.cs b/src/NexusMods.CrossPlatform/ProtocolRegistration/IProtocolRegistration.cs index e987c17545..d1d1a44094 100644 --- a/src/NexusMods.CrossPlatform/ProtocolRegistration/IProtocolRegistration.cs +++ b/src/NexusMods.CrossPlatform/ProtocolRegistration/IProtocolRegistration.cs @@ -1,40 +1,12 @@ -using NexusMods.Paths; - namespace NexusMods.CrossPlatform.ProtocolRegistration; /// -/// deals with protocol registration, that is: setting up this application system wide -/// as the default handler for a custom protocol (e.g. nxm://...) -/// For the actual code to be invoked when such a url is received, see NexusMods.Cli.ProtocolInvokation -/// -/// This is platform dependent functionality +/// Abstracts OS-specific protocol registration logic. /// public interface IProtocolRegistration { /// - /// register this application as a handler for a protocol (e.g. nxm://...). - /// This should be called every time the application runs for every protocol it handles - /// - /// The protocol to register for - /// the previous handler, if any - Task RegisterSelf(string protocol); - - /// - /// register an arbitrary command line as the handler for a protocol. - /// The primary usecase for this is to unregister the current application, potentially - /// restoring a previous handler - /// - /// The protocol to register for - /// Arbitrary friendly name for the protocol - /// The directory inside which the program is executed - /// The full commandline - /// the previous handler, if any - Task Register(string protocol, string friendlyName, string workingDirectory, string commandLine); - - /// - /// determine if this application is the handler for a protocol. This is based on the full url - /// of the calling process so another installation of the same application would _not_ count + /// Registers the App as a protocol handler for . /// - /// The protocol to check for - Task IsSelfHandler(string protocol); + Task RegisterHandler(string uriScheme, bool setAsDefaultHandler = true, CancellationToken cancellationToken = default); } diff --git a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationLinux.cs b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationLinux.cs index d3ed127397..20c9b819e7 100644 --- a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationLinux.cs +++ b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationLinux.cs @@ -1,6 +1,8 @@ using System.Runtime.Versioning; using System.Text; using CliWrap; +using Microsoft.Extensions.Logging; +using NexusMods.App.BuildInfo; using NexusMods.CrossPlatform.Process; using NexusMods.Paths; @@ -12,8 +14,12 @@ namespace NexusMods.CrossPlatform.ProtocolRegistration; [SupportedOSPlatform("linux")] public class ProtocolRegistrationLinux : IProtocolRegistration { - private const string BaseId = "nexusmods-app"; + private const string ApplicationId = "com.nexusmods.app"; + private const string DesktopFile = $"{ApplicationId}.desktop"; + private const string DesktopFileResourceName = $"NexusMods.CrossPlatform.{DesktopFile}"; + private const string ExecutablePathPlaceholder = "${INSTALL_EXEC}"; + private readonly ILogger _logger; private readonly IProcessFactory _processFactory; private readonly IFileSystem _fileSystem; private readonly IOSInterop _osInterop; @@ -21,72 +27,106 @@ public class ProtocolRegistrationLinux : IProtocolRegistration /// /// Constructor. /// - public ProtocolRegistrationLinux(IProcessFactory processFactory, IFileSystem fileSystem, IOSInterop osInterop) + public ProtocolRegistrationLinux( + ILogger logger, + IProcessFactory processFactory, + IFileSystem fileSystem, + IOSInterop osInterop) { + _logger = logger; _processFactory = processFactory; _fileSystem = fileSystem; _osInterop = osInterop; } /// - public async Task RegisterSelf(string protocol) + public async Task RegisterHandler(string uriScheme, bool setAsDefaultHandler = true, CancellationToken cancellationToken = default) { - var executable = _osInterop.GetOwnExe(); - - return await Register( - protocol, - friendlyName: $"{BaseId}-{protocol}.desktop", - workingDirectory: executable.Directory, - commandLine: $"{EscapeWhitespaceForCli(executable)} %u" - ); + if (CompileConstants.InstallationMethod != InstallationMethod.PackageManager) + { + var applicationsDirectory = _fileSystem.GetKnownPath(KnownPath.XDG_DATA_HOME).Combine("applications"); + _logger.LogInformation("Using applications directory `{Path}`", applicationsDirectory); + + try + { + await CreateDesktopFile(applicationsDirectory, cancellationToken: cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Exception while creating desktop file, the handler for `{Scheme}` might not work", uriScheme); + return; + } + + try + { + await UpdateMIMECacheDatabase(applicationsDirectory, cancellationToken: cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Exception while updating MIME cache database, see process logs for more details"); + return; + } + } + + if (setAsDefaultHandler) + { + try + { + await SetAsDefaultHandler(uriScheme, cancellationToken: cancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Exception while setting the default handler for `{Scheme}`, see the process logs for more details", uriScheme); + } + } } - private static string EscapeWhitespaceForCli(AbsolutePath path) => path.ToString().Replace(" ", @"\ "); - - /// - public async Task Register(string protocol, string friendlyName, string workingDirectory, string commandLine) + private async Task CreateDesktopFile(AbsolutePath applicationsDirectory, CancellationToken cancellationToken = default) { - var applicationsFolder = _fileSystem.GetKnownPath(KnownPath.HomeDirectory) - .Combine(".local/share/applications"); + var filePath = applicationsDirectory.Combine(DesktopFile); + var backupPath = filePath.AppendExtension(new Extension(".bak")); - applicationsFolder.CreateDirectory(); + if (filePath.FileExists) + { + _logger.LogInformation("Moving existing desktop file from `{From}` to `{To}`", filePath, backupPath); + } - var desktopEntryFile = applicationsFolder.Combine(friendlyName); + _logger.LogInformation("Creating desktop file at `{Path}`", filePath); - var sb = new StringBuilder(); - sb.AppendLine("[Desktop Entry]"); - sb.AppendLine($"Name=NexusMods.App {protocol.ToUpper()} Handler"); - sb.AppendLine("Terminal=false"); - sb.AppendLine("Type=Application"); - sb.AppendLine($"Path={workingDirectory}"); - sb.AppendLine($"Exec={commandLine}"); - sb.AppendLine($"MimeType=x-scheme-handler/{protocol}"); - sb.AppendLine("NoDisplay=true"); + await using var stream = typeof(ProtocolRegistrationLinux).Assembly.GetManifestResourceStream(DesktopFileResourceName); + if (stream is null) + { + _logger.LogError($"Manifest resource Stream for `{DesktopFileResourceName}` is null!"); + return; + } - await desktopEntryFile.WriteAllTextAsync(sb.ToString()); + using var sr = new StreamReader(stream, encoding: Encoding.UTF8); - var command = Cli.Wrap("update-desktop-database") - .WithArguments(applicationsFolder.ToString()); + var text = await sr.ReadToEndAsync(cancellationToken); + text = text.Replace(ExecutablePathPlaceholder, EscapeWhitespaceForCli(_osInterop.GetOwnExe())); - await _processFactory.ExecuteAsync(command); - return null; + await filePath.WriteAllTextAsync(text, cancellationToken); } - /// - public async Task IsSelfHandler(string protocol) + private async Task UpdateMIMECacheDatabase(AbsolutePath applicationsDirectory, CancellationToken cancellationToken = default) { - var stdOutBuffer = new StringBuilder(); + _logger.LogInformation("Updating MIME cache database"); - var command = Cli.Wrap("xdg-settings") - .WithArguments($"check default-url-scheme-handler {protocol} {BaseId}-{protocol}.desktop") - .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer)); + var command = Cli + .Wrap("update-desktop-database") + .WithArguments(EscapeWhitespaceForCli(applicationsDirectory)); - var res = await _processFactory.ExecuteAsync(command); - if (res.ExitCode != 0) return false; + await _processFactory.ExecuteAsync(command, cancellationToken: cancellationToken); + } - var stdOut = stdOutBuffer.ToString(); + private async Task SetAsDefaultHandler(string uriScheme, CancellationToken cancellationToken = default) + { + var command = Cli + .Wrap("xdg-settings") + .WithArguments($"set default-url-scheme-handler {uriScheme} {DesktopFile}"); - // might end with 0xA (LF) - return stdOut.StartsWith("yes", StringComparison.InvariantCultureIgnoreCase); + await _processFactory.ExecuteAsync(command, cancellationToken: cancellationToken); } + + private string EscapeWhitespaceForCli(AbsolutePath path) => path.ToNativeSeparators(_fileSystem.OS).Replace(" ", @"\ "); } diff --git a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationOSX.cs b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationOSX.cs index c62d8e747b..f5fcd3f145 100644 --- a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationOSX.cs +++ b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationOSX.cs @@ -1,26 +1,12 @@ -using NexusMods.Paths; - namespace NexusMods.CrossPlatform.ProtocolRegistration; /// -/// Protocol registration of OSX +/// Protocol registration of OSX. /// public class ProtocolRegistrationOSX : IProtocolRegistration { - /// - public Task RegisterSelf(string protocol) - { - throw new NotImplementedException(); - } - - /// - public Task Register(string protocol, string friendlyName, string workingDirectory, string commandLine) - { - throw new NotImplementedException(); - } - - /// - public Task IsSelfHandler(string protocol) + /// + public Task RegisterHandler(string uriScheme, bool setAsDefaultHandler = true, CancellationToken cancellationToken = default) { throw new NotImplementedException(); } diff --git a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationWindows.cs b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationWindows.cs index b7492932f7..88befbc427 100644 --- a/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationWindows.cs +++ b/src/NexusMods.CrossPlatform/ProtocolRegistration/ProtocolRegistrationWindows.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; using Microsoft.Win32; using NexusMods.CrossPlatform.Process; using NexusMods.Paths; @@ -11,54 +12,65 @@ namespace NexusMods.CrossPlatform.ProtocolRegistration; [SupportedOSPlatform("windows")] public class ProtocolRegistrationWindows : IProtocolRegistration { + private readonly ILogger _logger; private readonly IOSInterop _osInterop; + private readonly IFileSystem _fileSystem; /// - /// constructor + /// Constructor. /// - public ProtocolRegistrationWindows(IOSInterop osInterop) + public ProtocolRegistrationWindows( + ILogger logger, + IOSInterop osInterop, + IFileSystem fileSystem) { + _logger = logger; _osInterop = osInterop; + _fileSystem = fileSystem; } /// - public Task IsSelfHandler(string protocol) + public Task RegisterHandler(string uriScheme, bool setAsDefaultHandler = true, CancellationToken cancellationToken = default) { - using var key = GetClassKey(protocol); - using var commandKey = GetCommandKey(key); + try + { + UpdateRegistry(uriScheme); + } + catch (Exception e) + { + _logger.LogError(e, "Exception while updating registry to register protocol handler for `{Scheme}`", uriScheme); + } + + if (setAsDefaultHandler) + { + // On Windows, this is done via the "UserChoice" registry key. + // We can't set this automatically without much hassle. See this for details: + // https://www.winhelponline.com/blog/set-default-browser-file-associations-command-line-windows-10/ + _logger.LogDebug("Skipping setting default handler for `{Scheme}`", uriScheme); + } - return Task.FromResult(((string?)commandKey.GetValue("") ?? "").Contains(_osInterop.GetOwnExe().ToString())); + return Task.CompletedTask; } - /// - public Task Register(string protocol, string friendlyName, string workingDirectory, string commandLine) + private void UpdateRegistry(string uriScheme) { - using var key = GetClassKey(protocol); - key.SetValue("", "URL:" + friendlyName); + // https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85) + + using var key = GetClassKey(uriScheme); + key.SetValue("", "URL:Nexus Mods App"); key.SetValue("URL Protocol", ""); using var commandKey = GetCommandKey(key); - var res = (string?)commandKey.GetValue(""); - commandKey.SetValue("", commandLine); - commandKey.SetValue("WorkingDirectory", workingDirectory); + var executable = _osInterop.GetOwnExe(); - return Task.FromResult(res); + commandKey.SetValue("", $"\"{executable.ToNativeSeparators(_fileSystem.OS)}\" \"%1\""); + commandKey.SetValue("WorkingDirectory", $"\"{executable.Parent.ToNativeSeparators(_fileSystem.OS)}\""); } - /// - public Task RegisterSelf(string protocol) - { - var exePath = _osInterop.GetOwnExe(); - var osInfo = FileSystem.Shared.OS; - return Register(protocol, "NMA", "\""+exePath.Parent.ToNativeSeparators(osInfo) + "\"", - "\""+exePath.ToNativeSeparators(osInfo)+"\"" + - " protocol-invoke --url \"%1\""); - } - private static RegistryKey GetClassKey(string protocol) { - return Registry.CurrentUser.CreateSubKey("SOFTWARE\\Classes\\" + protocol); + return Registry.CurrentUser.CreateSubKey(@"SOFTWARE\Classes\" + protocol); } private static RegistryKey GetCommandKey(RegistryKey parent) diff --git a/tests/NexusMods.CrossPlatform.Tests/ProtocolRegistrationTests.Linux.cs b/tests/NexusMods.CrossPlatform.Tests/ProtocolRegistrationTests.Linux.cs deleted file mode 100644 index b5a8444855..0000000000 --- a/tests/NexusMods.CrossPlatform.Tests/ProtocolRegistrationTests.Linux.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Runtime.Versioning; -using FluentAssertions; -using NexusMods.CrossPlatform.Process; -using NexusMods.CrossPlatform.ProtocolRegistration; -using NexusMods.Paths; - -namespace NexusMods.CrossPlatform.Tests; - -public class ProtocolRegistrationTests(IOSInterop interop) -{ - [SkippableFact] - [SupportedOSPlatform("linux")] - public async Task ShouldWork_IsSelfHandler_Linux() - { - Skip.If(!OperatingSystem.IsLinux()); - - const string protocol = "foo"; - var processFactory = new FakeProcessFactory(0) - { - StandardOutput = "yes\n" - }; - - var protocolRegistration = new ProtocolRegistrationLinux(processFactory, FileSystem.Shared, interop); - var res = await protocolRegistration.IsSelfHandler(protocol); - res.Should().BeTrue(); - } - - [SkippableFact] - [SupportedOSPlatform("linux")] - public async Task ShouldError_IsSelfHandler_Linux() - { - Skip.If(!OperatingSystem.IsLinux()); - - const string protocol = "foo"; - var processFactory = new FakeProcessFactory(0) - { - StandardOutput = "no\n", - }; - - var protocolRegistration = new ProtocolRegistrationLinux(processFactory, FileSystem.Shared, interop); - var res = await protocolRegistration.IsSelfHandler(protocol); - res.Should().BeFalse(); - } -}