diff --git a/DesktopClock/App.xaml.cs b/DesktopClock/App.xaml.cs
index a24f451..13b6ceb 100644
--- a/DesktopClock/App.xaml.cs
+++ b/DesktopClock/App.xaml.cs
@@ -18,6 +18,7 @@ public partial class App : Application
protected override void OnStartup(StartupEventArgs e)
{
+ CrashHandler.Register(this);
base.OnStartup(e);
ThemeManager.Initialize();
diff --git a/DesktopClock/Properties/Settings.cs b/DesktopClock/Properties/Settings.cs
index a6576bc..a24b6e5 100644
--- a/DesktopClock/Properties/Settings.cs
+++ b/DesktopClock/Properties/Settings.cs
@@ -85,6 +85,23 @@ private Settings()
///
public static bool CanBeSaved { get; private set; }
+ ///
+ /// Saves one last time if the settings were loaded and can be saved, without throwing, for when the app is about to close after a crash.
+ ///
+ public static void TrySaveIfLoaded()
+ {
+ if (!_default.IsValueCreated || !CanBeSaved)
+ return;
+
+ try
+ {
+ Default.Save();
+ }
+ catch
+ {
+ }
+ }
+
///
/// Checks if the settings file exists on the disk.
///
diff --git a/DesktopClock/Utilities/CrashHandler.cs b/DesktopClock/Utilities/CrashHandler.cs
new file mode 100644
index 0000000..357a618
--- /dev/null
+++ b/DesktopClock/Utilities/CrashHandler.cs
@@ -0,0 +1,93 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Windows;
+using DesktopClock.Properties;
+
+namespace DesktopClock.Utilities;
+
+///
+/// Handles crashes so the clock doesn't just vanish: it saves settings one last time, writes the error to a log file, tells the user where it is, and then exits.
+///
+public static class CrashHandler
+{
+ ///
+ /// The log starts over once it grows past this, so repeated crashes can't fill the disk.
+ ///
+ private const long MaxLogSize = 1024 * 1024;
+
+ private static int _handling;
+
+ ///
+ /// Handles unhandled exceptions on the UI thread and on background threads, such as the clock's timer.
+ ///
+ public static void Register(Application app)
+ {
+ app.DispatcherUnhandledException += (_, e) => HandleAndExit(e.Exception);
+ AppDomain.CurrentDomain.UnhandledException += (_, e) => HandleAndExit(e.ExceptionObject as Exception);
+ }
+
+ private static void HandleAndExit(Exception exception)
+ {
+ // Only the first crash is handled; another one while it's being handled exits right away instead of stacking up dialogs.
+ if (Interlocked.Exchange(ref _handling, 1) == 0)
+ {
+ Settings.TrySaveIfLoaded();
+ var logPath = TryWriteLog(exception);
+ ShowMessage(logPath);
+ }
+
+ // Staying open in an unknown state could leave a clock that looks fine but has stopped, so always close.
+ Environment.Exit(1);
+ }
+
+ ///
+ /// Appends the error to a log next to the app, or in the temp folder if that one can't be written to.
+ ///
+ /// The path of the log that was written, or null if neither could be.
+ private static string TryWriteLog(Exception exception)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(App.MainFileInfo.Name) + ".log";
+ var entry =
+ $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}\r\n" +
+ $"{App.MainFileDisplayText} on {RuntimeInformation.OSDescription.Trim()}, {RuntimeInformation.FrameworkDescription}\r\n" +
+ $"{exception}\r\n\r\n";
+
+ foreach (var folder in new[] { App.MainFileInfo.DirectoryName, Path.GetTempPath() })
+ {
+ try
+ {
+ var path = Path.Combine(folder, fileName);
+
+ if (File.Exists(path) && new FileInfo(path).Length > MaxLogSize)
+ File.Delete(path);
+
+ File.AppendAllText(path, entry);
+ return path;
+ }
+ catch
+ {
+ }
+ }
+
+ return null;
+ }
+
+ private static void ShowMessage(string logPath)
+ {
+ try
+ {
+ var details = logPath == null ? "" : $"Details were saved to:\n{logPath}\n\n";
+
+ MessageBox.Show(
+ "DesktopClock ran into a problem and needs to close.\n\n" +
+ details +
+ "Please report it at https://github.com/danielchalmers/DesktopClock/issues and include that file.",
+ "DesktopClock", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ catch
+ {
+ }
+ }
+}