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