diff --git a/src/UniGetUI.Avalonia/App.axaml.cs b/src/UniGetUI.Avalonia/App.axaml.cs index 59d9c1431..57d23e90b 100644 --- a/src/UniGetUI.Avalonia/App.axaml.cs +++ b/src/UniGetUI.Avalonia/App.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Styling; using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Views; +using UniGetUI.Avalonia.Views.DialogPages; using UniGetUI.PackageEngine; using CoreSettings = global::UniGetUI.Core.SettingsEngine.Settings; @@ -47,7 +48,7 @@ public override void OnFrameworkInitializationCompleted() ApplyTheme(CoreSettings.GetValue(CoreSettings.K.PreferredTheme)); var mainWindow = new MainWindow(); desktop.MainWindow = mainWindow; - _ = AvaloniaBootstrapper.InitializeAsync(); + _ = StartupAsync(mainWindow); } base.OnFrameworkInitializationCompleted(); @@ -80,6 +81,27 @@ private static void ExpandMacOSPath() catch { /* keep the existing PATH if the shell can't be launched */ } } + private static async Task StartupAsync(MainWindow mainWindow) + { + // Show crash report from the previous session and wait for the user + // to dismiss it before continuing with normal startup. + if (File.Exists(CrashHandler.PendingCrashFile)) + { + try + { + string report = File.ReadAllText(CrashHandler.PendingCrashFile); + File.Delete(CrashHandler.PendingCrashFile); + // Yield once so the main window has time to open before + // ShowDialog tries to attach to it as owner. + await Task.Yield(); + await new CrashReportWindow(report).ShowDialog(mainWindow); + } + catch { /* must not prevent normal startup */ } + } + + await AvaloniaBootstrapper.InitializeAsync(); + } + public static void ApplyTheme(string value) { Current!.RequestedThemeVariant = value switch diff --git a/src/UniGetUI.Avalonia/CrashHandler.cs b/src/UniGetUI.Avalonia/CrashHandler.cs new file mode 100644 index 000000000..b2cfb29eb --- /dev/null +++ b/src/UniGetUI.Avalonia/CrashHandler.cs @@ -0,0 +1,116 @@ +using System.Diagnostics; +using System.Text; +using UniGetUI.Core.Data; +using UniGetUI.Core.Tools; + +namespace UniGetUI.Avalonia; + +public static class CrashHandler +{ + public static readonly string PendingCrashFile = + Path.Combine(Path.GetTempPath(), "UniGetUI_pending_crash.txt"); + + public static void ReportFatalException(Exception e) + { + Debugger.Break(); + + string langName = "Unknown"; + try + { + langName = CoreTools.GetCurrentLocale(); + } + catch { } + + static string GetExceptionData(Exception ex) + { + try + { + var b = new StringBuilder(); + foreach (var key in ex.Data.Keys) + b.AppendLine($"{key}: {ex.Data[key]}"); + string r = b.ToString(); + return r.Any() ? r : "No extra data was provided"; + } + catch (Exception inner) + { + return $"Failed to get exception Data with exception {inner.Message}"; + } + } + + string iReport; + try + { + var integrityReport = IntegrityTester.CheckIntegrity(false); + iReport = IntegrityTester.GetReadableReport(integrityReport); + } + catch (Exception ex) + { + iReport = "Failed to compute integrity report: " + ex.GetType() + ": " + ex.Message; + } + + string errorString = $$""" + Environment details: + OS version: {{Environment.OSVersion.VersionString}} + Language: {{langName}} + APP Version: {{CoreData.VersionName}} + APP Build number: {{CoreData.BuildNumber}} + Executable: {{Environment.ProcessPath}} + Command-line arguments: {{Environment.CommandLine}} + + Integrity report: + {{iReport.Replace("\n", "\n ")}} + + Exception type: {{e.GetType()?.Name}} ({{e.GetType()}}) + Crash HResult: 0x{{(uint)e.HResult:X}} ({{(uint)e.HResult}}, {{e.HResult}}) + Crash Message: {{e.Message}} + + Crash Data: + {{GetExceptionData(e).Replace("\n", "\n ")}} + + Crash Trace: + {{e.StackTrace?.Replace("\n", "\n ")}} + """; + + try + { + int depth = 0; + while (e.InnerException is not null) + { + depth++; + e = e.InnerException; + errorString += + "\n\n\n\n" + + $$""" + ——————————————————————————————————————————————————————————— + Inner exception details (depth level: {{depth}}) + Crash HResult: 0x{{(uint)e.HResult:X}} ({{(uint)e.HResult}}, {{e.HResult}}) + Crash Message: {{e.Message}} + + Crash Data: + {{GetExceptionData(e).Replace("\n", "\n ")}} + + Crash Traceback: + {{e.StackTrace?.Replace("\n", "\n ")}} + """; + } + + if (depth == 0) + errorString += "\n\n\nNo inner exceptions found"; + } + catch { } + + Console.WriteLine(errorString); + + // Persist crash data so the next normal app launch can show the report. + try + { + File.WriteAllText(PendingCrashFile, errorString, Encoding.UTF8); + } + catch + { + // If we can't write the file, nothing more we can do — just exit. + } + + Environment.Exit(1); + } +} diff --git a/src/UniGetUI.Avalonia/Program.cs b/src/UniGetUI.Avalonia/Program.cs index 271d958a0..ea2f59c1b 100644 --- a/src/UniGetUI.Avalonia/Program.cs +++ b/src/UniGetUI.Avalonia/Program.cs @@ -9,8 +9,13 @@ sealed class Program // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + CrashHandler.ReportFatalException((Exception)e.ExceptionObject); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 3b3ee8888..3c3542999 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -167,6 +167,9 @@ ManagersHomepage.axaml Code + + CrashReportWindow.axaml + OperationOutputWindow.axaml diff --git a/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml b/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml new file mode 100644 index 000000000..6d5a5106c --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/DialogPages/CrashReportWindow.axaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +