From bacaec389e936bb0cb7daacdd50714b37e3d1bc3 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Wed, 30 Sep 2026 20:26:47 +0200 Subject: [PATCH] (feature): show unhandled UI errors in a copyable dialog and keep the desktop shortcut on reinstall --- CHANGELOG.md | 10 ++ assets/badges/coverage-branches.svg | 8 +- assets/badges/coverage-lines.svg | 14 +- assets/badges/coverage-methods.svg | 8 +- data/com.maks_it.uscheduler.metainfo.xml | 6 + src/MaksIT.UScheduler.Shared/ErrorReport.cs | 78 ++++++++++ .../Helpers/UserSettingsPath.cs | 7 + .../ErrorReportTests.cs | 40 +++++ .../Dialogs/ErrorDialog.cs | 146 ++++++++++++++++++ .../MaksIT.UScheduler.UI.csproj | 2 +- src/MaksIT.UScheduler.UI/Program.cs | 36 ++++- src/MaksIT.UScheduler.UI/Themes/Dark.axaml | 6 + .../ViewModels/ErrorReportViewModel.cs | 19 +++ .../Windows/ErrorWindow.axaml | 47 ++++++ .../Windows/ErrorWindow.axaml.cs | 31 ++++ .../MaksIT.UScheduler.csproj | 2 +- utils/plugins/Desktop/DesktopPackSupport.psm1 | 75 ++++++++- utils/plugins/Desktop/WindowsInstaller.psm1 | 7 +- 18 files changed, 517 insertions(+), 25 deletions(-) create mode 100644 src/MaksIT.UScheduler.Shared/ErrorReport.cs create mode 100644 src/MaksIT.UScheduler.Tests/ErrorReportTests.cs create mode 100644 src/MaksIT.UScheduler.UI/Dialogs/ErrorDialog.cs create mode 100644 src/MaksIT.UScheduler.UI/ViewModels/ErrorReportViewModel.cs create mode 100644 src/MaksIT.UScheduler.UI/Windows/ErrorWindow.axaml create mode 100644 src/MaksIT.UScheduler.UI/Windows/ErrorWindow.axaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 63371c3..4dc77aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-09-30 + +### Added + +- Unhandled UI errors open a dialog with the exception details and a **Copy** button. The same text is saved under `%AppData%\MaksIT\UScheduler\logs`. + +### Fixed + +- Windows setup keeps an existing desktop shortcut when the checkbox is off on reinstall, and removes a leftover duplicate (`UScheduler (2).lnk`) so a checked box does not leave two icons. + ## [1.2.0] - 2026-09-21 ### Added diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index e6dcdc3..04eaf2d 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 25.1% + + Branch Coverage: 30.3% @@ -15,7 +15,7 @@ Branch Coverage - - 25.1% + + 30.3% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index 9880c4b..d53f75e 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,21 +1,21 @@ - - Line Coverage: 30% + + Line Coverage: 34.3% - + - - + + Line Coverage - - 30% + + 34.3% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index db1bed2..acfc15e 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 46.3% + + Method Coverage: 51.3% @@ -15,7 +15,7 @@ Method Coverage - - 46.3% + + 51.3% diff --git a/data/com.maks_it.uscheduler.metainfo.xml b/data/com.maks_it.uscheduler.metainfo.xml index bba8c16..e8ee69e 100644 --- a/data/com.maks_it.uscheduler.metainfo.xml +++ b/data/com.maks_it.uscheduler.metainfo.xml @@ -39,6 +39,12 @@ 768 + + https://github.com/MAKS-IT-COM/uscheduler/blob/v1.3.0/CHANGELOG.md + +

Unhandled UI errors open a copyable dialog and are saved to a crash log. Windows setup keeps an existing desktop shortcut on reinstall.

+ + https://github.com/MAKS-IT-COM/uscheduler/blob/v1.2.0/CHANGELOG.md diff --git a/src/MaksIT.UScheduler.Shared/ErrorReport.cs b/src/MaksIT.UScheduler.Shared/ErrorReport.cs new file mode 100644 index 0000000..24c4deb --- /dev/null +++ b/src/MaksIT.UScheduler.Shared/ErrorReport.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using MaksIT.UScheduler.Shared.Helpers; + + +namespace MaksIT.UScheduler.Shared; + + +/// Formats unhandled exceptions for a copyable dialog and a crash log file. +public static class ErrorReport { + public static string Capture(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var body = Format(exception); + var path = TryWrite(body); + if (string.IsNullOrWhiteSpace(path)) + return body; + return body + Environment.NewLine + Environment.NewLine + "Log: " + path; + } + + public static string Format(Exception exception) { + ArgumentNullException.ThrowIfNull(exception); + var text = new StringBuilder(); + text.AppendLine("UScheduler " + Version()); + text.AppendLine("MaksIT"); + text.AppendLine(DateTimeOffset.UtcNow.ToString("u")); + text.AppendLine(RuntimeInformation.OSDescription); + text.AppendLine(RuntimeInformation.FrameworkDescription); + text.AppendLine((Environment.Is64BitProcess ? "64-bit" : "32-bit") + " process"); + text.AppendLine(); + AppendException(text, exception); + return text.ToString().TrimEnd(); + } + + public static string? TryWrite(string report) { + try { + var dir = UserSettingsPath.LogsDirectory(HostPaths.ProductFolder); + Directory.CreateDirectory(dir); + var name = "crash-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss") + "-" + Environment.ProcessId + ".txt"; + var path = Path.Combine(dir, name); + File.WriteAllText(path, report ?? ""); + return path; + } + catch { + return null; + } + } + + private static string Version() { + var version = Assembly.GetEntryAssembly()?.GetName().Version; + return version is null ? "" : version.ToString(); + } + + private static void AppendException(StringBuilder text, Exception exception) { + var seen = new HashSet(); + var current = exception; + var depth = 0; + while (current is not null && seen.Add(current)) { + if (depth > 0) + text.AppendLine().AppendLine("--- inner ---"); + text.AppendLine(current.GetType().FullName); + text.AppendLine(current.Message); + if (!string.IsNullOrWhiteSpace(current.StackTrace)) + text.AppendLine(current.StackTrace); + if (current is AggregateException aggregate) { + foreach (var inner in aggregate.InnerExceptions) { + if (inner is null || ReferenceEquals(inner, current.InnerException) || !seen.Add(inner)) + continue; + text.AppendLine().AppendLine("--- aggregate ---"); + text.AppendLine(inner.ToString()); + } + } + + current = current.InnerException; + depth++; + } + } +} diff --git a/src/MaksIT.UScheduler.Shared/Helpers/UserSettingsPath.cs b/src/MaksIT.UScheduler.Shared/Helpers/UserSettingsPath.cs index 640b3dd..dd4d533 100644 --- a/src/MaksIT.UScheduler.Shared/Helpers/UserSettingsPath.cs +++ b/src/MaksIT.UScheduler.Shared/Helpers/UserSettingsPath.cs @@ -17,6 +17,13 @@ public static class UserSettingsPath { public static string Get(string product, string fileName = "settings.json") => Combine(Environment.SpecialFolder.ApplicationData, product, fileName); + public static string LogsDirectory(string product) => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Manufacturer, + product, + "logs"); + /// /// Machine-wide settings shared by every user and the Windows service /// (LocalSystem). On Windows this is all-users AppData: diff --git a/src/MaksIT.UScheduler.Tests/ErrorReportTests.cs b/src/MaksIT.UScheduler.Tests/ErrorReportTests.cs new file mode 100644 index 0000000..c6d7cfd --- /dev/null +++ b/src/MaksIT.UScheduler.Tests/ErrorReportTests.cs @@ -0,0 +1,40 @@ +using MaksIT.UScheduler.Shared; + + +namespace MaksIT.UScheduler.Tests; + +public class ErrorReportTests { + [Fact] + public void Format_IncludesTypeMessageInnerAndStack() { + Exception thrown; + try { + throw new InvalidOperationException("outer", new ArgumentException("inner")); + } + catch (Exception ex) { + thrown = ex; + } + + var text = ErrorReport.Format(thrown); + Assert.Contains("UScheduler", text, StringComparison.Ordinal); + Assert.Contains("MaksIT", text, StringComparison.Ordinal); + Assert.Contains("InvalidOperationException", text, StringComparison.Ordinal); + Assert.Contains("outer", text, StringComparison.Ordinal); + Assert.Contains("--- inner ---", text, StringComparison.Ordinal); + Assert.Contains("ArgumentException", text, StringComparison.Ordinal); + Assert.Contains("inner", text, StringComparison.Ordinal); + Assert.Contains(nameof(Format_IncludesTypeMessageInnerAndStack), text, StringComparison.Ordinal); + } + + [Fact] + public void Format_IncludesAggregateInners() { + var error = new AggregateException( + "batch", + new InvalidOperationException("one"), + new ArgumentException("two")); + var text = ErrorReport.Format(error); + Assert.Contains("AggregateException", text, StringComparison.Ordinal); + Assert.Contains("one", text, StringComparison.Ordinal); + Assert.Contains("two", text, StringComparison.Ordinal); + Assert.Contains("--- aggregate ---", text, StringComparison.Ordinal); + } +} diff --git a/src/MaksIT.UScheduler.UI/Dialogs/ErrorDialog.cs b/src/MaksIT.UScheduler.UI/Dialogs/ErrorDialog.cs new file mode 100644 index 0000000..7c75259 --- /dev/null +++ b/src/MaksIT.UScheduler.UI/Dialogs/ErrorDialog.cs @@ -0,0 +1,146 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using MaksIT.UScheduler.Shared; +using MaksIT.UScheduler.UI.ViewModels; +using MaksIT.UScheduler.UI.Windows; + + +namespace MaksIT.UScheduler.UI.Dialogs; + + +internal static class ErrorDialog { + private static int _open; + + public static void Report(Exception? exception) => + Present(exception, wait: false); + + public static void ReportBlocking(Exception? exception) => + Present(exception, wait: true); + + private static void Present(Exception? exception, bool wait) { + if (exception is null) + return; + var report = ErrorReport.Capture(exception); + var dispatcher = TryDispatcher(); + if (dispatcher is null) { + if (wait) + ShowStandalone(report); + return; + } + + if (dispatcher.CheckAccess()) { + if (wait) + ShowUntilClosed(report); + else + _ = ShowAsync(report); + return; + } + + if (wait) + dispatcher.Invoke(() => ShowUntilClosed(report)); + else + dispatcher.Post(() => _ = ShowAsync(report)); + } + + private static Dispatcher? TryDispatcher() { + try { + return Dispatcher.UIThread; + } + catch { + return null; + } + } + + private static async Task ShowAsync(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + await window.ShowDialog(owner); + return; + } + + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + var closed = new TaskCompletionSource(); + window.Closed += (_, _) => closed.TrySetResult(); + window.Show(); + await closed.Task; + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowUntilClosed(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + var window = Create(report); + var owner = ActiveWindow(); + var closed = false; + window.Closed += (_, _) => closed = true; + if (owner is { IsVisible: true }) { + window.WindowStartupLocation = WindowStartupLocation.CenterOwner; + window.Show(owner); + } + else { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.Show(); + } + + var dispatcher = Dispatcher.UIThread; + while (!closed) + dispatcher.RunJobs(); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static void ShowStandalone(string report) { + if (Interlocked.Exchange(ref _open, 1) != 0) + return; + try { + AppBuilder.Configure() + .UsePlatformDetect() + .AfterSetup(builder => { + if (builder.Instance?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return; + life.ShutdownMode = ShutdownMode.OnMainWindowClose; + life.MainWindow = Create(report); + }) + .StartWithClassicDesktopLifetime([]); + } + catch { + } + finally { + Interlocked.Exchange(ref _open, 0); + } + } + + private static ErrorWindow Create(string report) => + new() { + DataContext = new ErrorReportViewModel(report), + WindowStartupLocation = WindowStartupLocation.CenterScreen + }; + + private static Window? ActiveWindow() { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime life) + return null; + foreach (var window in life.Windows) { + if (window.IsActive && window is not ErrorWindow) + return window; + } + + return life.MainWindow is ErrorWindow ? null : life.MainWindow; + } +} diff --git a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj index 81a5462..87472e5 100644 --- a/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj +++ b/src/MaksIT.UScheduler.UI/MaksIT.UScheduler.UI.csproj @@ -4,7 +4,7 @@ WinExe net10.0 MaksIT.UScheduler.UI - 1.2.0 + 1.3.0 enable enable true diff --git a/src/MaksIT.UScheduler.UI/Program.cs b/src/MaksIT.UScheduler.UI/Program.cs index 35e5a55..c768a68 100644 --- a/src/MaksIT.UScheduler.UI/Program.cs +++ b/src/MaksIT.UScheduler.UI/Program.cs @@ -1,18 +1,48 @@ using Avalonia; using Avalonia.Logging; +using Avalonia.Threading; +using MaksIT.UScheduler.UI.Dialogs; namespace MaksIT.UScheduler.UI; internal static class Program { [STAThread] - public static void Main(string[] args) => - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) { + AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandled; + TaskScheduler.UnobservedTaskException += OnUnobservedTask; + try { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) { + ErrorDialog.ReportBlocking(ex); + } + } // Linux: X11/XWayland. Avalonia 12.1.2 native Wayland still hangs on GNOME's // xdg_toplevel.configure(0, 0) and never maps a window (GNOME app icon). public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() - .LogToTrace(LogEventLevel.Warning); + .LogToTrace(LogEventLevel.Warning) + .AfterSetup(_ => Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandled); + + private static void OnDispatcherUnhandled(object? sender, DispatcherUnhandledExceptionEventArgs e) { + e.Handled = true; + ErrorDialog.Report(e.Exception); + } + + private static void OnDomainUnhandled(object? sender, UnhandledExceptionEventArgs e) { + if (e.ExceptionObject is not Exception ex) + return; + if (e.IsTerminating) + ErrorDialog.ReportBlocking(ex); + else + ErrorDialog.Report(ex); + } + + private static void OnUnobservedTask(object? sender, UnobservedTaskExceptionEventArgs e) { + e.SetObserved(); + ErrorDialog.Report(e.Exception); + } } diff --git a/src/MaksIT.UScheduler.UI/Themes/Dark.axaml b/src/MaksIT.UScheduler.UI/Themes/Dark.axaml index f4cfbfe..f4e3fe9 100644 --- a/src/MaksIT.UScheduler.UI/Themes/Dark.axaml +++ b/src/MaksIT.UScheduler.UI/Themes/Dark.axaml @@ -11,6 +11,12 @@ +