diff --git a/src/Shared/StartupBundleArguments.cs b/src/Shared/StartupBundleArguments.cs new file mode 100644 index 0000000000..2831818748 --- /dev/null +++ b/src/Shared/StartupBundleArguments.cs @@ -0,0 +1,107 @@ +using UniGetUI.Interface; + +namespace UniGetUI.Shared; + +internal static class StartupBundleArguments +{ + private static readonly string[] BundleExtensions = [".ubundle", ".json", ".yaml", ".xml"]; + + private static readonly string[] ValueTakingArguments = + [ + IpcTransportOptions.TransportArgument, + IpcTransportOptions.TcpPortArgument, + IpcTransportOptions.NamedPipeArgument, + IpcTransportOptions.CliTransportArgument, + IpcTransportOptions.CliTcpPortArgument, + IpcTransportOptions.CliNamedPipeArgument, + ]; + + public static List Resolve(IReadOnlyList args, string workingDirectory) + { + List bundles = []; + foreach ((_, string path) in Enumerate(args, workingDirectory)) + { + bundles.Add(path); + } + + return bundles; + } + + public static string[] Normalize(IReadOnlyList args, string workingDirectory) + { + string[] normalized = [.. args]; + foreach ((int index, string path) in Enumerate(args, workingDirectory)) + { + normalized[index] = path; + } + + return normalized; + } + + private static IEnumerable<(int Index, string Path)> Enumerate( + IReadOnlyList args, + string workingDirectory + ) + { + for (int i = 0; i < args.Count; i++) + { + string arg = Unquote(args[i]); + + if (arg.StartsWith('-')) + { + if (ValueTakingArguments.Contains(arg, StringComparer.Ordinal)) + { + i++; + } + + continue; + } + + if (TryResolveBundleFile(arg, workingDirectory, out string path)) + { + yield return (i, path); + } + } + } + + private static bool TryResolveBundleFile(string arg, string workingDirectory, out string fullPath) + { + fullPath = string.Empty; + + if (arg.Length == 0) + { + return false; + } + + try + { + if (!BundleExtensions.Contains(Path.GetExtension(arg), StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + string candidate = Path.GetFullPath(arg, workingDirectory); + if (!File.Exists(candidate)) + { + return false; + } + + fullPath = candidate; + return true; + } + catch (Exception) + { + return false; + } + } + + private static string Unquote(string arg) + { + if (arg.Length > 1 && (arg[0] == '"' || arg[0] == '\'') && arg[^1] == arg[0]) + { + return arg[1..^1]; + } + + return arg; + } +} diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 1142b6c6ac..a508f1dfbb 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -165,10 +165,10 @@ void CloseSplashOnce(object? s, EventArgs e) if (!CoreData.WasDaemon) mainWindow.Show(); - _ = StartupAsync(mainWindow); + _ = StartupAsync(mainWindow, desktop.Args ?? []); } - private static async Task StartupAsync(MainWindow mainWindow) + private static async Task StartupAsync(MainWindow mainWindow, string[] args) { // Show crash report from the previous session and wait for the user // to dismiss it before continuing with normal startup. @@ -188,6 +188,15 @@ private static async Task StartupAsync(MainWindow mainWindow) } await AvaloniaBootstrapper.InitializeAsync(); + + if (CoreData.WasDaemon) + { + StartupArgumentProcessor.WarnIfBundlesIgnored( + args, $"the launch requested {AvaloniaCliHandler.DAEMON}"); + return; + } + + await StartupArgumentProcessor.ProcessAsync(args); } private static void HandleSecondaryInstanceArgs(MainWindow mainWindow, string[] args) @@ -211,9 +220,15 @@ private static void HandleSecondaryInstanceArgs(MainWindow mainWindow, string[] } if (isDaemonLaunch) + { + StartupArgumentProcessor.WarnIfBundlesIgnored( + args, $"the launch requested {AvaloniaCliHandler.DAEMON}"); return; + } mainWindow.ShowFromTray(); + + _ = StartupArgumentProcessor.ProcessAsync(args); } public static void ApplyTheme(string value) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs index 93f7806c6c..d48405ee53 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAppHost.cs @@ -10,6 +10,7 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.Tools; using UniGetUI.Interface; +using UniGetUI.Shared; namespace UniGetUI.Avalonia.Infrastructure; @@ -18,7 +19,41 @@ public static class AvaloniaAppHost private static Mutex? _singleInstanceMutex; private static FileStream? _singleInstanceLock; - public static event Action? SecondaryInstanceArgsReceived; + private static Action? _secondaryInstanceArgsReceived; + private static readonly Queue _bufferedSecondaryInstanceArgs = new(); + + public static event Action? SecondaryInstanceArgsReceived + { + add + { + _secondaryInstanceArgsReceived += value; + + if (value is null || _bufferedSecondaryInstanceArgs.Count == 0) + return; + + string[][] buffered = [.. _bufferedSecondaryInstanceArgs]; + _bufferedSecondaryInstanceArgs.Clear(); + + Dispatcher.UIThread.Post(() => + { + foreach (string[] bufferedArgs in buffered) + value(bufferedArgs); + }); + } + remove => _secondaryInstanceArgsReceived -= value; + } + + private static void RaiseSecondaryInstanceArgs(string[] args) + { + if (_secondaryInstanceArgsReceived is null) + { + Logger.Info("Buffering forwarded arguments until the main window is ready"); + _bufferedSecondaryInstanceArgs.Enqueue(args); + return; + } + + _secondaryInstanceArgsReceived(args); + } public static void Run(string[] args) { @@ -80,6 +115,8 @@ Welcome to UniGetUI Version {CoreData.VersionName} // would otherwise bind it to the worker thread and make Win32Platform.Initialize throw. _ = Dispatcher.UIThread; + args = StartupBundleArguments.Normalize(args, Environment.CurrentDirectory); + if (!TryRegisterSingleInstance(args)) { return; @@ -139,7 +176,7 @@ private static bool TryRegisterWithMutex(string[] args) if (createdNew) { - SingleInstanceRedirector.StartListener(a => SecondaryInstanceArgsReceived?.Invoke(a)); + SingleInstanceRedirector.StartListener(RaiseSecondaryInstanceArgs); return true; } @@ -173,7 +210,7 @@ private static bool TryRegisterWithFileLock(string[] args) return true; } - SingleInstanceRedirector.StartListener(a => SecondaryInstanceArgsReceived?.Invoke(a)); + SingleInstanceRedirector.StartListener(RaiseSecondaryInstanceArgs); return true; } } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index c37fa9ce84..2f6ab79c40 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -22,6 +22,10 @@ namespace UniGetUI.Avalonia.Infrastructure; internal static class AvaloniaBootstrapper { private static bool _hasStarted; + private static readonly TaskCompletionSource _initialized = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public static Task Initialized => _initialized.Task; // Coalesces broker-unavailable notifications: during bulk operations every failed // package raises the event, but only one dialog should be visible at a time. @@ -38,12 +42,22 @@ public static async Task InitializeAsync() _hasStarted = true; Logger.Info("Starting Avalonia shell bootstrap"); - await Task.WhenAll( - InitializeSharedServicesAsync(), - InitializePackageEngineAsync() - ); + try + { + await Task.WhenAll( + InitializeSharedServicesAsync(), + InitializePackageEngineAsync() + ); + + await RunPostLoadChecksAsync(); + } + catch (Exception) + { + _initialized.TrySetResult(false); + throw; + } - await RunPostLoadChecksAsync(); + _initialized.TrySetResult(true); Logger.Info("Avalonia shell bootstrap completed"); } diff --git a/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs b/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs new file mode 100644 index 0000000000..beb12e4f4b --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/StartupArgumentProcessor.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using UniGetUI.Avalonia.ViewModels; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; +using UniGetUI.Shared; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class StartupArgumentProcessor +{ + public static void WarnIfBundlesIgnored(IReadOnlyList args, string reason) + { + if (args.Count == 0) + return; + + List bundles = StartupBundleArguments.Resolve(args, Environment.CurrentDirectory); + foreach (string bundle in bundles) + { + Logger.Warn($"Ignoring the package bundle {bundle}: {reason}"); + } + } + + public static async Task ProcessAsync(IReadOnlyList args) + { + if (args.Count == 0) + { + return; + } + + List bundles = StartupBundleArguments.Resolve(args, Environment.CurrentDirectory); + if (bundles.Count == 0) + { + return; + } + + if (bundles.Count > 1) + { + Logger.Warn( + $"{bundles.Count} package bundles were passed on the command line; only {bundles[0]} will be loaded"); + } + + if (!await AvaloniaBootstrapper.Initialized) + { + Logger.Warn( + $"Could not load the package bundle {bundles[0]}: UniGetUI failed to finish initializing"); + return; + } + + if (MainWindow.Instance is not { } window) + { + Logger.Warn($"Could not load the package bundle {bundles[0]}: the main window is not available"); + return; + } + + if (window.DataContext is not MainWindowViewModel viewModel) + { + Logger.Warn($"Could not load the package bundle {bundles[0]}: the main window has no view model"); + return; + } + + if (!window.IsVisible) + { + window.ShowFromTray(); + } + + Logger.ImportantInfo($"Loading the package bundle {bundles[0]} requested on the command line"); + await viewModel.LoadBundleFromFileAsync(bundles[0]); + } +} diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index b1920ce7c4..eeec41faf4 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -217,6 +217,7 @@ + diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index d17fe2c226..810e121371 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -758,6 +758,12 @@ public async Task LoadCloudBundleAsync(string content) await BundlesPage.OpenFromString(content, BundleFormatType.UBUNDLE, "GitHub Gist"); } + public async Task LoadBundleFromFileAsync(string path) + { + NavigateTo(PageType.Bundles); + await BundlesPage.OpenFromFile(path); + } + private async Task ShowAboutDialog() { Sidebar.SelectNavButtonForPage(PageType.Null); diff --git a/src/UniGetUI.Tests/StartupBundleArgumentsTests.cs b/src/UniGetUI.Tests/StartupBundleArgumentsTests.cs new file mode 100644 index 0000000000..5f04fce92c --- /dev/null +++ b/src/UniGetUI.Tests/StartupBundleArgumentsTests.cs @@ -0,0 +1,141 @@ +using UniGetUI.Interface; +using UniGetUI.Shared; + +namespace UniGetUI.Tests; + +public sealed class StartupBundleArgumentsTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(StartupBundleArgumentsTests), + Guid.NewGuid().ToString("N") + ); + + public StartupBundleArgumentsTests() + { + Directory.CreateDirectory(_testRoot); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + private string CreateFile(string name) + { + string path = Path.Combine(_testRoot, name); + File.WriteAllText(path, "{}"); + return path; + } + + [Theory] + [InlineData("bundle.ubundle")] + [InlineData("bundle.json")] + [InlineData("bundle.yaml")] + [InlineData("bundle.xml")] + [InlineData("bundle.UBUNDLE")] + public void Resolve_AcceptsSupportedExtensionsAsRelativePaths(string name) + { + string expected = CreateFile(name); + + List bundles = StartupBundleArguments.Resolve([name], _testRoot); + + Assert.Equal([expected], bundles); + } + + [Fact] + public void Resolve_AcceptsFullyQualifiedPaths() + { + string expected = CreateFile("bundle.ubundle"); + + List bundles = StartupBundleArguments.Resolve([expected], Path.GetTempPath()); + + Assert.Equal([expected], bundles); + } + + [Fact] + public void Resolve_StripsSurroundingQuotes() + { + string expected = CreateFile("bundle.ubundle"); + + List bundles = StartupBundleArguments.Resolve([$"\"{expected}\""], _testRoot); + + Assert.Equal([expected], bundles); + } + + [Fact] + public void Resolve_IgnoresMissingFiles() + { + Assert.Empty(StartupBundleArguments.Resolve(["missing.ubundle"], _testRoot)); + } + + [Fact] + public void Resolve_IgnoresUnsupportedExtensions() + { + CreateFile("bundle.txt"); + + Assert.Empty(StartupBundleArguments.Resolve(["bundle.txt"], _testRoot)); + } + + [Fact] + public void Resolve_IgnoresFlagsAndTheirValues() + { + string bundle = CreateFile("bundle.ubundle"); + + List bundles = StartupBundleArguments.Resolve( + ["--daemon", IpcTransportOptions.CliNamedPipeArgument, bundle], + _testRoot + ); + + Assert.Empty(bundles); + } + + [Fact] + public void Resolve_FindsBundlesAfterAFlagValuePair() + { + string bundle = CreateFile("bundle.ubundle"); + + List bundles = StartupBundleArguments.Resolve( + [IpcTransportOptions.CliTcpPortArgument, "7058", "bundle.ubundle"], + _testRoot + ); + + Assert.Equal([bundle], bundles); + } + + [Fact] + public void Resolve_ReturnsEveryBundleInOrder() + { + string first = CreateFile("first.ubundle"); + string second = CreateFile("second.json"); + + List bundles = StartupBundleArguments.Resolve( + ["first.ubundle", "--daemon", "second.json"], + _testRoot + ); + + Assert.Equal([first, second], bundles); + } + + [Fact] + public void Normalize_RewritesRelativeBundlePathsAndLeavesOtherArgumentsUntouched() + { + string bundle = CreateFile("bundle.ubundle"); + + string[] normalized = StartupBundleArguments.Normalize( + ["--daemon", "bundle.ubundle", "missing.ubundle"], + _testRoot + ); + + Assert.Equal(["--daemon", bundle, "missing.ubundle"], normalized); + } + + [Fact] + public void Normalize_KeepsEmptyArgumentListsEmpty() + { + Assert.Empty(StartupBundleArguments.Normalize([], _testRoot)); + } +} diff --git a/src/UniGetUI.Tests/UniGetUI.Tests.csproj b/src/UniGetUI.Tests/UniGetUI.Tests.csproj index c7ca3d7dfb..f61fb16a71 100644 --- a/src/UniGetUI.Tests/UniGetUI.Tests.csproj +++ b/src/UniGetUI.Tests/UniGetUI.Tests.csproj @@ -45,6 +45,7 @@ +