diff --git a/NexusMods.App.sln b/NexusMods.App.sln index bad01875df..a68c172cb1 100644 --- a/NexusMods.App.sln +++ b/NexusMods.App.sln @@ -286,6 +286,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Steam. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Hashes", "src\Abstractions\NexusMods.Abstractions.Hashes\NexusMods.Abstractions.Hashes.csproj", "{AF703852-D7B0-4BAD-8C75-B6046C6F0490}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Logging", "src\Abstractions\NexusMods.Abstractions.Logging\NexusMods.Abstractions.Logging.csproj", "{9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -752,6 +754,10 @@ Global {AF703852-D7B0-4BAD-8C75-B6046C6F0490}.Debug|Any CPU.Build.0 = Debug|Any CPU {AF703852-D7B0-4BAD-8C75-B6046C6F0490}.Release|Any CPU.ActiveCfg = Release|Any CPU {AF703852-D7B0-4BAD-8C75-B6046C6F0490}.Release|Any CPU.Build.0 = Release|Any CPU + {9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -885,6 +891,7 @@ Global {24457AAA-8954-4BD6-8EB5-168EAC6EFB1B} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} {17023DB9-8E31-4397-B3E1-141149987865} = {897C4198-884F-448A-B0B0-C2A6D971EAE0} {AF703852-D7B0-4BAD-8C75-B6046C6F0490} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} + {9DE1C2AC-927A-4BC6-B2A1-7016902F8BAE} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501} diff --git a/src/Abstractions/NexusMods.Abstractions.Logging/IObservableExceptionSource.cs b/src/Abstractions/NexusMods.Abstractions.Logging/IObservableExceptionSource.cs new file mode 100644 index 0000000000..8b25b23f04 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Logging/IObservableExceptionSource.cs @@ -0,0 +1,10 @@ +namespace NexusMods.Abstractions.Logging; + +/// +/// A global source of exceptions that have been observed by the application. +/// This allows for UI and other systems to tap into log messages. +/// +public interface IObservableExceptionSource +{ + IObservable Exceptions { get; } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Logging/LogMessage.cs b/src/Abstractions/NexusMods.Abstractions.Logging/LogMessage.cs new file mode 100644 index 0000000000..a054684232 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Logging/LogMessage.cs @@ -0,0 +1,12 @@ +namespace NexusMods.Abstractions.Logging; + +/// +/// A generic log message. Exists as a record to de-couple exceptions and log messages +/// from the backend logging targets +/// +/// The attached Exception (if any) +/// The log's message +public record LogMessage(Exception? Exception, string Message) +{ + +} diff --git a/src/Abstractions/NexusMods.Abstractions.Logging/NexusMods.Abstractions.Logging.csproj b/src/Abstractions/NexusMods.Abstractions.Logging/NexusMods.Abstractions.Logging.csproj new file mode 100644 index 0000000000..d96e1d11be --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Logging/NexusMods.Abstractions.Logging.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/ApplyControlViewModel.cs index 2c9101f9a1..dfba2c99ef 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/ApplyControlViewModel.cs @@ -26,7 +26,7 @@ public class ApplyControlViewModel : AViewModel, IApplyC private readonly IJobMonitor _jobMonitor; private readonly LoadoutId _loadoutId; - private readonly IOverlayController _overlayController; + private readonly IServiceProvider _serviceProvider; private readonly GameInstallMetadataId _gameMetadataId; [Reactive] private bool CanApply { get; set; } = true; @@ -41,7 +41,7 @@ public class ApplyControlViewModel : AViewModel, IApplyC public ApplyControlViewModel(LoadoutId loadoutId, IServiceProvider serviceProvider, IJobMonitor jobMonitor, IOverlayController overlayController, GameRunningTracker gameRunningTracker) { _loadoutId = loadoutId; - _overlayController = overlayController; + _serviceProvider = serviceProvider; _syncService = serviceProvider.GetRequiredService(); _conn = serviceProvider.GetRequiredService(); _jobMonitor = serviceProvider.GetRequiredService(); @@ -118,7 +118,7 @@ await Task.Run(async () => catch (ExecutableInUseException) { var marker = NexusMods.Abstractions.Loadouts.Loadout.Load(_conn.Db, _loadoutId); - await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController, marker.Installation.Name); + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_serviceProvider, marker.Installation.Name); } } } diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/LaunchButtonViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/LaunchButtonViewModel.cs index 89508b485f..7492fafdeb 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/LaunchButtonViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControl/LaunchButtonViewModel.cs @@ -32,16 +32,16 @@ public class LaunchButtonViewModel : AViewModel, ILaunch private readonly IToolManager _toolManager; private readonly IConnection _conn; private readonly IJobMonitor _monitor; - private readonly IOverlayController _overlayController; + private readonly IServiceProvider _serviceProvider; private readonly GameRunningTracker _gameRunningTracker; - public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IOverlayController overlayController, GameRunningTracker gameRunningTracker) + public LaunchButtonViewModel(ILogger logger, IToolManager toolManager, IConnection conn, IJobMonitor monitor, IServiceProvider serviceProvider, GameRunningTracker gameRunningTracker) { _logger = logger; _toolManager = toolManager; _conn = conn; _monitor = monitor; - _overlayController = overlayController; + _serviceProvider = serviceProvider; _gameRunningTracker = gameRunningTracker; this.WhenActivated(cd => @@ -72,7 +72,7 @@ await Task.Run(async () => } catch (ExecutableInUseException) { - await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_overlayController, marker.Installation.Name); + await MessageBoxOkViewModel.ShowGameAlreadyRunningError(_serviceProvider, marker.Installation.Name); } catch (Exception ex) { diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index 1c93eea1e3..b878f94bde 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -85,6 +85,7 @@ + diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs index d1279bc15c..a998be6435 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/IMessageBoxOkViewModel.cs @@ -1,3 +1,4 @@ +using NexusMods.App.UI.Controls.MarkdownRenderer; using R3; namespace NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; @@ -6,6 +7,19 @@ namespace NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; /// public interface IMessageBoxOkViewModel : IOverlayViewModel { + /// + /// A short title for the message box. + /// public string Title { get; set; } + + /// + /// A description of what's happening. + /// public string Description { get; set; } + + /// + /// If provided, this will be displayed in a markdown control below the description. Use this + /// for more descriptive information. + /// + public IMarkdownRendererViewModel? MarkdownRenderer { get; set; } } diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml index ea8e8dbd65..1ef4270fbb 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml @@ -11,9 +11,9 @@ xmlns:base="clr-namespace:NexusMods.App.UI.Overlays.Generic.MessageBox.Base" xmlns:resources="clr-namespace:NexusMods.App.UI.Resources"> - + - + @@ -30,7 +30,10 @@ - + + + + diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs index 20ff042144..c6f96e42cd 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkView.axaml.cs @@ -1,4 +1,5 @@ using System.Reactive.Disposables; +using System.Reactive.Linq; using Avalonia.ReactiveUI; using R3; using ReactiveUI; @@ -21,7 +22,15 @@ public MessageBoxOkView() this.OneWayBind(ViewModel, vm => vm.Description, v => v.MessageTextBlock.Text) .DisposeWith(disposables); - + + this.OneWayBind(ViewModel, vm => vm.MarkdownRenderer, v => v.MarkdownRendererViewModelViewHost.ViewModel) + .DisposeWith(disposables); + + this.WhenAnyValue(view => view.ViewModel!.MarkdownRenderer) + .Select(vm => vm != null) + .BindTo(this, v => v.MarkdownRendererViewModelViewHost.IsVisible) + .DisposeWith(disposables); + // Bind commands OkButton.Command = ReactiveCommand.Create(() => { diff --git a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs index 278107ead5..f936510e1f 100644 --- a/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs +++ b/src/NexusMods.App.UI/Overlays/Generic/MessageBox/Ok/MessageBoxOkViewModel.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.DependencyInjection; +using NexusMods.App.UI.Controls.MarkdownRenderer; using NexusMods.App.UI.Resources; using R3; using ReactiveUI.Fody.Helpers; @@ -11,15 +13,32 @@ public class MessageBoxOkViewModel : AOverlayViewModel /// Shows the 'Game is already Running' error when you try to synchronize and a game is already running (usually on Windows). /// - public static async Task ShowGameAlreadyRunningError(IOverlayController overlayController, string gameName) + public static Task ShowGameAlreadyRunningError(IServiceProvider serviceProvider, string gameName) { - var viewModel = new MessageBoxOkViewModel() + return Show(serviceProvider, Language.ErrorGameAlreadyRunning_Title, string.Format(Language.ErrorGameAlreadyRunning_Description, gameName)); + } + + public static async Task Show(IServiceProvider serviceProvider, string title, string description, string? markdown = null) + { + var overlayController = serviceProvider.GetRequiredService(); + + IMarkdownRendererViewModel? markdownRenderer = null; + if (markdown != null) + { + markdownRenderer = serviceProvider.GetRequiredService(); + markdownRenderer.Contents = markdown; + } + + var viewModel = new MessageBoxOkViewModel { - Title = Language.ErrorGameAlreadyRunning_Title, - Description = string.Format(Language.ErrorGameAlreadyRunning_Description, gameName) , + Title = title, + Description = description, + MarkdownRenderer = markdownRenderer, }; await overlayController.EnqueueAndWait(viewModel); } diff --git a/src/NexusMods.App.UI/Windows/MainWindowViewModel.cs b/src/NexusMods.App.UI/Windows/MainWindowViewModel.cs index a876bad08e..3a71c956c7 100644 --- a/src/NexusMods.App.UI/Windows/MainWindowViewModel.cs +++ b/src/NexusMods.App.UI/Windows/MainWindowViewModel.cs @@ -2,6 +2,7 @@ using System.Reactive.Disposables; using System.Reactive.Linq; using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.Logging; using NexusMods.Abstractions.NexusWebApi; using NexusMods.Abstractions.UI; using NexusMods.App.UI.Controls.DevelopmentBuildBanner; @@ -11,6 +12,7 @@ using NexusMods.App.UI.LeftMenu; using NexusMods.App.UI.Overlays; using NexusMods.App.UI.Overlays.AlphaWarning; +using NexusMods.App.UI.Overlays.Generic.MessageBox.Ok; using NexusMods.App.UI.Overlays.Login; using NexusMods.App.UI.Overlays.MetricsOptIn; using NexusMods.App.UI.Overlays.Updater; @@ -48,9 +50,12 @@ public MainWindowViewModel( Spine = serviceProvider.GetRequiredService(); DevelopmentBuildBanner = serviceProvider.GetRequiredService(); - + this.WhenActivated(d => { + ConnectErrors(serviceProvider) + .DisposeWith(d); + var alphaWarningViewModel = serviceProvider.GetRequiredService(); alphaWarningViewModel.WorkspaceController = WorkspaceController; alphaWarningViewModel.Controller = overlayController; @@ -103,7 +108,34 @@ public MainWindowViewModel( } }); } - + + private IDisposable ConnectErrors(IServiceProvider provider) + { + var source = provider.GetService(); + if (source is null) + return Disposable.Empty; + + return source.Exceptions + .Subscribe(msg => + { + var title = "Unhandled Exception"; + var description = msg.Message; + string? details = null; + if (msg.Exception != null) + { + details = $""" + ### Exception Details + ``` + {msg.Exception} + ``` + """; + } + + Task.Run(() => MessageBoxOkViewModel.Show(provider, title, description, details)); + } + ); + } + internal void OnClose() { // NOTE(erri120): This gets called by the View and can't be inside the disposable diff --git a/src/NexusMods.App/NexusMods.App.csproj b/src/NexusMods.App/NexusMods.App.csproj index 73d4c58028..251bf8d952 100644 --- a/src/NexusMods.App/NexusMods.App.csproj +++ b/src/NexusMods.App/NexusMods.App.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NexusMods.App/ObservableLoggingTarget.cs b/src/NexusMods.App/ObservableLoggingTarget.cs new file mode 100644 index 0000000000..5076185344 --- /dev/null +++ b/src/NexusMods.App/ObservableLoggingTarget.cs @@ -0,0 +1,19 @@ +using System.Reactive.Subjects; +using NexusMods.Abstractions.Logging; +using NLog; +using NLog.Targets; + +namespace NexusMods.App; + +public class ObservableLoggingTarget : Target, IObservableExceptionSource +{ + public IObservable Exceptions => _exceptions; + + private Subject _exceptions = new(); + + protected override void Write(LogEventInfo logEvent) + { + if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Fatal) + _exceptions.OnNext(new LogMessage(logEvent.Exception, logEvent.FormattedMessage)); + } +} diff --git a/src/NexusMods.App/Program.cs b/src/NexusMods.App/Program.cs index b484f4f155..a86a4b940e 100644 --- a/src/NexusMods.App/Program.cs +++ b/src/NexusMods.App/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.Logging; using NexusMods.Abstractions.Serialization; using NexusMods.Abstractions.Settings; using NexusMods.Abstractions.Telemetry; @@ -219,6 +220,7 @@ private static IHost BuildHost( ExperimentalSettings experimentalSettings, GameLocatorSettings? gameLocatorSettings = null) { + var observableTarget = new ObservableLoggingTarget(); var host = new HostBuilder().ConfigureServices(services => { var s = services.AddApp( @@ -227,18 +229,20 @@ private static IHost BuildHost( experimentalSettings: experimentalSettings, gameLocatorSettings: gameLocatorSettings).Validate(); + s.AddSingleton(_ => observableTarget); + if (startupMode.IsAvaloniaDesigner) { s.OverrideSettingsForTests(settings => settings with { UseInMemoryDataModel = true, }); } }) - .ConfigureLogging((_, builder) => AddLogging(builder, loggingSettings, startupMode)) + .ConfigureLogging((_, builder) => AddLogging(observableTarget, builder, loggingSettings, startupMode)) .Build(); return host; } - private static void AddLogging(ILoggingBuilder loggingBuilder, LoggingSettings settings, StartupMode startupMode) + private static void AddLogging(ObservableLoggingTarget observableLoggingTarget, ILoggingBuilder loggingBuilder, LoggingSettings settings, StartupMode startupMode) { var fs = FileSystem.Shared; var config = new NLog.Config.LoggingConfiguration(); @@ -279,6 +283,7 @@ private static void AddLogging(ILoggingBuilder loggingBuilder, LoggingSettings s } config.AddRuleForAllLevels(fileTarget); + config.AddRuleForAllLevels(observableLoggingTarget); // NOTE(erri120): RemoveLoggerFactoryFilter prevents