Browse Source

Add sentry and exception dialog handler

pull/55/head
Ionite 1 year ago
parent
commit
7429cf295c
No known key found for this signature in database
  1. 14
      StabilityMatrix.Avalonia/App.axaml.cs
  2. 25
      StabilityMatrix.Avalonia/Controls/AppWindowBase.cs
  3. 13
      StabilityMatrix.Avalonia/Controls/UserControlBase.cs
  4. 25
      StabilityMatrix.Avalonia/DesignData/DesignData.cs
  5. 124
      StabilityMatrix.Avalonia/Program.cs
  6. 16
      StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs
  7. 83
      StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml
  8. 25
      StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml.cs
  9. 6
      StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs

14
StabilityMatrix.Avalonia/App.axaml.cs

@ -25,6 +25,7 @@ using Polly.Contrib.WaitAndRetry;
using Polly.Extensions.Http; using Polly.Extensions.Http;
using Polly.Timeout; using Polly.Timeout;
using Refit; using Refit;
using Sentry;
using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Controls;
using StabilityMatrix.Avalonia.DesignData; using StabilityMatrix.Avalonia.DesignData;
using StabilityMatrix.Avalonia.Helpers; using StabilityMatrix.Avalonia.Helpers;
@ -147,6 +148,7 @@ public sealed class App : Application
services.AddTransient<SelectModelVersionViewModel>(); services.AddTransient<SelectModelVersionViewModel>();
services.AddTransient<SelectDataDirectoryViewModel>(); services.AddTransient<SelectDataDirectoryViewModel>();
services.AddTransient<LaunchOptionsViewModel>(); services.AddTransient<LaunchOptionsViewModel>();
services.AddTransient<ExceptionViewModel>();
services.AddSingleton<UpdateViewModel>(); services.AddSingleton<UpdateViewModel>();
// Other transients (usually sub view models) // Other transients (usually sub view models)
@ -171,6 +173,7 @@ public sealed class App : Application
.Register(provider.GetRequiredService<CheckpointFolder>) .Register(provider.GetRequiredService<CheckpointFolder>)
.Register(provider.GetRequiredService<CheckpointFile>) .Register(provider.GetRequiredService<CheckpointFile>)
.Register(provider.GetRequiredService<RefreshBadgeViewModel>) .Register(provider.GetRequiredService<RefreshBadgeViewModel>)
.Register(provider.GetRequiredService<ExceptionViewModel>)
.Register(provider.GetRequiredService<ProgressManagerViewModel>)); .Register(provider.GetRequiredService<ProgressManagerViewModel>));
} }
@ -188,6 +191,7 @@ public sealed class App : Application
services.AddTransient<SelectDataDirectoryDialog>(); services.AddTransient<SelectDataDirectoryDialog>();
services.AddTransient<LaunchOptionsDialog>(); services.AddTransient<LaunchOptionsDialog>();
services.AddTransient<UpdateDialog>(); services.AddTransient<UpdateDialog>();
services.AddTransient<ExceptionDialog>();
// Controls // Controls
services.AddTransient<RefreshBadge>(); services.AddTransient<RefreshBadge>();
@ -398,10 +402,9 @@ public sealed class App : Application
{ {
Layout = "${message}" Layout = "${message}"
}); });
LogManager.Configuration = logConfig; // Sentry
// Add Sentry to NLog if enabled if (SentrySdk.IsEnabled)
if (true)
{ {
logConfig.AddSentry(o => logConfig.AddSentry(o =>
{ {
@ -417,6 +420,9 @@ public sealed class App : Application
}); });
} }
LogManager.Configuration = logConfig;
return logConfig; return logConfig;
} }
} }

25
StabilityMatrix.Avalonia/Controls/AppWindowBase.cs

@ -1,22 +1,33 @@
using Avalonia.Interactivity; using System.Diagnostics.CodeAnalysis;
using AsyncAwaitBestPractices;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Windowing; using FluentAvalonia.UI.Windowing;
using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels;
namespace StabilityMatrix.Avalonia.Controls; namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public class AppWindowBase : AppWindow public class AppWindowBase : AppWindow
{ {
protected AppWindowBase() protected AppWindowBase()
{ {
AddHandler(LoadedEvent, OnLoaded);
} }
public virtual async void OnLoaded(object? sender, RoutedEventArgs e) protected override void OnLoaded(RoutedEventArgs e)
{ {
if (DataContext is not ViewModelBase viewModel) return; base.OnLoaded(e);
// ReSharper disable once MethodHasAsyncOverload if (DataContext is ViewModelBase viewModel)
viewModel.OnLoaded(); {
await viewModel.OnLoadedAsync(); // Run synchronous load then async load
viewModel.OnLoaded();
// Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnLoadedAsync();
}).SafeFireAndForget();
}
} }
} }

13
StabilityMatrix.Avalonia/Controls/UserControlBase.cs

@ -1,10 +1,13 @@
using Avalonia.Controls; using System.Diagnostics.CodeAnalysis;
using AsyncAwaitBestPractices;
using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading; using Avalonia.Threading;
using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels;
namespace StabilityMatrix.Avalonia.Controls; namespace StabilityMatrix.Avalonia.Controls;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")]
public class UserControlBase : UserControl public class UserControlBase : UserControl
{ {
static UserControlBase() static UserControlBase()
@ -13,12 +16,18 @@ public class UserControlBase : UserControl
(cls, args) => cls.OnLoadedEvent(args)); (cls, args) => cls.OnLoadedEvent(args));
} }
// ReSharper disable once UnusedParameter.Global
protected virtual void OnLoadedEvent(RoutedEventArgs? e) protected virtual void OnLoadedEvent(RoutedEventArgs? e)
{ {
if (DataContext is not ViewModelBase viewModel) return; if (DataContext is not ViewModelBase viewModel) return;
// Run synchronous load then async load
viewModel.OnLoaded(); viewModel.OnLoaded();
// Can't block here so we'll run as async on UI thread // Can't block here so we'll run as async on UI thread
Dispatcher.UIThread.InvokeAsync(async () => await viewModel.OnLoadedAsync()); Dispatcher.UIThread.InvokeAsync(async () =>
{
await viewModel.OnLoadedAsync();
}).SafeFireAndForget();
} }
} }

25
StabilityMatrix.Avalonia/DesignData/DesignData.cs

@ -4,6 +4,8 @@ using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Media;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels; using StabilityMatrix.Avalonia.ViewModels;
@ -22,6 +24,7 @@ using StabilityMatrix.Core.Updater;
namespace StabilityMatrix.Avalonia.DesignData; namespace StabilityMatrix.Avalonia.DesignData;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public static class DesignData public static class DesignData
{ {
[NotNull] public static IServiceProvider? Services { get; set; } [NotNull] public static IServiceProvider? Services { get; set; }
@ -213,6 +216,8 @@ public static class DesignData
[NotNull] public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; } [NotNull] public static LaunchOptionsViewModel? LaunchOptionsViewModel { get; private set; }
[NotNull] public static UpdateViewModel? UpdateViewModel { get; private set; } [NotNull] public static UpdateViewModel? UpdateViewModel { get; private set; }
public static ServiceManager<ViewModelBase> DialogFactory =>
Services.GetRequiredService<ServiceManager<ViewModelBase>>();
public static MainWindowViewModel MainWindowViewModel => public static MainWindowViewModel MainWindowViewModel =>
Services.GetRequiredService<MainWindowViewModel>(); Services.GetRequiredService<MainWindowViewModel>();
public static LaunchPageViewModel LaunchPageViewModel => public static LaunchPageViewModel LaunchPageViewModel =>
@ -233,6 +238,26 @@ public static class DesignData
Services.GetRequiredService<SelectDataDirectoryViewModel>(); Services.GetRequiredService<SelectDataDirectoryViewModel>();
public static ProgressManagerViewModel ProgressManagerViewModel => public static ProgressManagerViewModel ProgressManagerViewModel =>
Services.GetRequiredService<ProgressManagerViewModel>(); Services.GetRequiredService<ProgressManagerViewModel>();
public static ExceptionViewModel ExceptionViewModel =>
DialogFactory.Get<ExceptionViewModel>(viewModel =>
{
// Use try-catch to generate traceback information
try
{
try
{
throw new OperationCanceledException("Example");
}
catch (OperationCanceledException e)
{
throw new AggregateException(e);
}
}
catch (AggregateException e)
{
viewModel.Exception = e;
}
});
public static RefreshBadgeViewModel RefreshBadgeViewModel => new() public static RefreshBadgeViewModel RefreshBadgeViewModel => new()
{ {

124
StabilityMatrix.Avalonia/Program.cs

@ -1,8 +1,23 @@
using System; using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NLog;
using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.FontAwesome;
using Sentry;
using StabilityMatrix.Avalonia.ViewModels.Dialogs;
using StabilityMatrix.Avalonia.Views;
using StabilityMatrix.Avalonia.Views.Dialogs;
namespace StabilityMatrix.Avalonia; namespace StabilityMatrix.Avalonia;
@ -14,8 +29,113 @@ public class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break. // yet and stuff might break.
[STAThread] [STAThread]
public static void Main(string[] args) => BuildAvaloniaApp() public static void Main(string[] args)
.StartWithClassicDesktopLifetime(args); {
// Configure exception dialog for unhandled exceptions
if (!Debugger.IsAttached || args.Contains("--debug-exception-dialog"))
{
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
}
// Configure Sentry
if (!args.Contains("--no-sentry"))
{
ConfigureSentry();
}
// Run main app
try
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception e)
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
var mainWindow = lifetime.MainWindow;
if (mainWindow is not null)
{
var dialog = new ExceptionDialog
{
ShowAsDialog = true,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
DataContext = new ExceptionViewModel
{
Exception = e
}
};
// Show synchronously without blocking UI thread
// https://github.com/AvaloniaUI/Avalonia/issues/4810#issuecomment-704259221
var cts = new CancellationTokenSource();
dialog.ShowDialog(mainWindow).ContinueWith(t =>
{
cts.Cancel();
// Shutdown app
App.Shutdown();
Dispatcher.UIThread.InvokeShutdown();
Environment.Exit(1);
}, TaskScheduler.FromCurrentSynchronizationContext());
Dispatcher.UIThread.MainLoop(cts.Token);
}
}
}
}
private static void ConfigureSentry()
{
SentrySdk.Init(o =>
{
o.Dsn = "https://eac7a5ea065d44cf9a8565e0f1817da2@o4505314753380352.ingest.sentry.io/4505314756067328";
o.StackTraceMode = StackTraceMode.Enhanced;
o.TracesSampleRate = 1.0;
o.IsGlobalModeEnabled = true;
// Enables Sentry's "Release Health" feature.
o.AutoSessionTracking = true;
// 1.0 to capture 100% of transactions for performance monitoring.
o.TracesSampleRate = 1.0;
#if DEBUG
o.Environment = "Development";
#endif
});
}
private static async void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is not Exception ex) return;
var logger = LogManager.GetCurrentClassLogger();
logger.Fatal(ex, "Unhandled {Type}: {Message}", ex.GetType().Name, ex.Message);
if (SentrySdk.IsEnabled)
{
SentrySdk.CaptureException(ex);
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
{
var mainWindow = lifetime.MainWindow;
if (mainWindow is not null)
{
var dialog = new ExceptionDialog
{
DataContext = new ExceptionViewModel
{
Exception = ex,
}
};
await dialog.ShowDialog(mainWindow);
}
else
{
logger.Fatal("Could not resolve main window in unhandled exception handler.");
}
}
}
// Avalonia configuration, don't remove; also used by visual designer. // Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp() public static AppBuilder BuildAvaloniaApp()

16
StabilityMatrix.Avalonia/ViewModels/Dialogs/ExceptionViewModel.cs

@ -0,0 +1,16 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using StabilityMatrix.Avalonia.Views.Dialogs;
using StabilityMatrix.Core.Attributes;
namespace StabilityMatrix.Avalonia.ViewModels.Dialogs;
[View(typeof(ExceptionDialog))]
public partial class ExceptionViewModel : ViewModelBase
{
public Exception? Exception { get; set; }
public string? Message => Exception?.Message;
public string? ExceptionType => Exception?.GetType().Name ?? "";
}

83
StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml

@ -0,0 +1,83 @@
<controls:AppWindowBase xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:dialogs="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Dialogs"
xmlns:mocks="clr-namespace:StabilityMatrix.Avalonia.DesignData"
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"
d:DataContext="{x:Static mocks:DesignData.ExceptionViewModel}"
x:DataType="dialogs:ExceptionViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="550"
Icon="/Assets/Icon.ico"
Width="800"
Height="550"
Title="Stability Matrix - Unexpected Error"
x:Class="StabilityMatrix.Avalonia.Views.Dialogs.ExceptionDialog">
<Grid RowDefinitions="auto,*,auto">
<Grid Grid.Row="1" RowDefinitions="auto,*,auto">
<!-- Info -->
<TextBlock
Grid.Row="0"
Theme="{DynamicResource SubtitleTextBlockStyle}"
Margin="16"
Text="An unexpected error occured"
TextWrapping="Wrap"
VerticalAlignment="Top" />
<!-- Exception details -->
<DockPanel Grid.Row="1">
<Expander
VerticalAlignment="Top"
Header="{Binding ExceptionType, Mode=OneWay, TargetNullValue=-, FallbackValue=Unknown Exception}"
IsExpanded="True"
Padding="12,12,12,12"
Margin="16,0,16,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel DataContext="{Binding Exception}" Spacing="4">
<!-- Exception Message -->
<TextBlock Theme="{DynamicResource BodyStrongTextBlockStyle}" Text="Details" />
<SelectableTextBlock Text="{Binding Message}" Margin="0,0,0,8" />
<!-- Callstack -->
<TextBlock Theme="{DynamicResource BodyStrongTextBlockStyle}" Text="Callstack" />
<controls:Card
Margin="0,0,0,8"
Background="{DynamicResource ComboBoxDropDownBackground}">
<SelectableTextBlock
FontSize="13"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
TextWrapping="Wrap"
Text="{Binding StackTrace}" />
</controls:Card>
<!-- Inner exception -->
<TextBlock
Theme="{DynamicResource BodyStrongTextBlockStyle}"
IsVisible="{Binding InnerException, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="Inner exception" />
<controls:Card Background="{DynamicResource ComboBoxDropDownBackground}">
<SelectableTextBlock
FontSize="13"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
TextWrapping="Wrap"
IsVisible="{Binding InnerException, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="{Binding InnerException}" />
</controls:Card>
</StackPanel>
</ScrollViewer>
</Expander>
</DockPanel>
<!-- Close Button -->
<Button
Name="ExitButton"
IsDefault="True"
Content="Exit Application"
Click="ExitButton_OnClick"
Grid.Row="2"
HorizontalAlignment="Right"
Margin="16"
Padding="8">
</Button>
</Grid>
</Grid>
</controls:AppWindowBase>

25
StabilityMatrix.Avalonia/Views/Dialogs/ExceptionDialog.axaml.cs

@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using StabilityMatrix.Avalonia.Controls;
namespace StabilityMatrix.Avalonia.Views.Dialogs;
public partial class ExceptionDialog : AppWindowBase
{
public ExceptionDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
[SuppressMessage("ReSharper", "UnusedParameter.Local")]
private void ExitButton_OnClick(object? sender, RoutedEventArgs e)
{
Close();
}
}

6
StabilityMatrix.Avalonia/Views/MainWindow.axaml.cs

@ -50,10 +50,10 @@ public partial class MainWindow : AppWindowBase
TryEnableMicaEffect(); TryEnableMicaEffect();
} }
} }
public override void OnLoaded(object? sender, RoutedEventArgs e) protected override void OnLoaded(RoutedEventArgs e)
{ {
base.OnLoaded(sender, e); base.OnLoaded(e);
NotificationService?.Initialize(this); NotificationService?.Initialize(this);
} }

Loading…
Cancel
Save