/ src / modules / cmdpal / Microsoft.CmdPal.UI / Helpers / GlobalErrorHandler.cs
GlobalErrorHandler.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using ManagedCommon;
  6  using Microsoft.CmdPal.Core.Common.Services.Reports;
  7  using Windows.Win32;
  8  using Windows.Win32.Foundation;
  9  using Windows.Win32.UI.WindowsAndMessaging;
 10  using SystemUnhandledExceptionEventArgs = System.UnhandledExceptionEventArgs;
 11  using XamlUnhandledExceptionEventArgs = Microsoft.UI.Xaml.UnhandledExceptionEventArgs;
 12  
 13  namespace Microsoft.CmdPal.UI.Helpers;
 14  
 15  /// <summary>
 16  /// Global error handler for Command Palette.
 17  /// </summary>
 18  internal sealed partial class GlobalErrorHandler : IDisposable
 19  {
 20      private readonly ErrorReportBuilder _errorReportBuilder = new();
 21      private Options? _options;
 22      private App? _app;
 23  
 24      // GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
 25      internal void Register(App app, Options options)
 26      {
 27          ArgumentNullException.ThrowIfNull(app);
 28          ArgumentNullException.ThrowIfNull(options);
 29  
 30          _options = options;
 31  
 32          _app = app;
 33          _app.UnhandledException += App_UnhandledException;
 34          TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
 35          AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
 36      }
 37  
 38      private void App_UnhandledException(object sender, XamlUnhandledExceptionEventArgs e)
 39      {
 40          // Exceptions thrown on the main UI thread are handled here.
 41          if (e.Exception != null)
 42          {
 43              HandleException(e.Exception, Context.MainThreadException);
 44          }
 45      }
 46  
 47      private void CurrentDomain_UnhandledException(object sender, SystemUnhandledExceptionEventArgs e)
 48      {
 49          // Exceptions thrown on background threads are handled here.
 50          if (e.ExceptionObject is Exception ex)
 51          {
 52              HandleException(ex, Context.AppDomainUnhandledException);
 53          }
 54      }
 55  
 56      private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
 57      {
 58          // This event is raised only when a faulted Task is garbage-collected
 59          // without its exception being observed. It is NOT raised immediately
 60          // when the Task faults; timing depends on GC finalization.
 61          e.SetObserved();
 62          HandleException(e.Exception, Context.UnobservedTaskException);
 63      }
 64  
 65      private void HandleException(Exception ex, Context context)
 66      {
 67          Logger.LogError($"Unhandled exception detected ({context})", ex);
 68  
 69          if (context == Context.MainThreadException)
 70          {
 71              var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
 72  
 73              StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
 74  
 75              string message;
 76              string caption;
 77              try
 78              {
 79                  message = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Message");
 80                  caption = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Caption");
 81              }
 82              catch
 83              {
 84                  // The resource loader may not be available if the exception occurred during startup.
 85                  // Fall back to hardcoded strings in that case.
 86                  message = "Command Palette has encountered a fatal error and must close.";
 87                  caption = "Command Palette - Fatal error";
 88              }
 89  
 90              PInvoke.MessageBox(
 91                  HWND.Null,
 92                  message,
 93                  caption,
 94                  MESSAGEBOX_STYLE.MB_ICONERROR);
 95          }
 96      }
 97  
 98      private static string? StoreReport(string report, bool storeOnDesktop)
 99      {
100          // Generate a unique name for the report file; include timestamp and a random zero-padded number to avoid collisions
101          // in case of crash storm.
102          var name = FormattableString.Invariant($"CmdPal_ErrorReport_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{Random.Shared.Next(100000):D5}.log");
103  
104          // Always store a copy in log directory, this way it is available for Bug Report Tool
105          string? reportPath = null;
106          if (Logger.CurrentVersionLogDirectoryPath != null)
107          {
108              reportPath = Save(report, name, static () => Logger.CurrentVersionLogDirectoryPath);
109          }
110  
111          // Optionally store a copy on the desktop for user (in)convenience
112          if (storeOnDesktop)
113          {
114              var path = Save(report, name, static () => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory));
115  
116              // show the desktop copy if both succeeded
117              if (path != null)
118              {
119                  reportPath = path;
120              }
121          }
122  
123          return reportPath;
124  
125          static string? Save(string reportContent, string reportFileName, Func<string> directory)
126          {
127              try
128              {
129                  var logDirectory = directory();
130                  Directory.CreateDirectory(logDirectory);
131                  var reportFilePath = Path.Combine(logDirectory, reportFileName);
132                  File.WriteAllText(reportFilePath, reportContent);
133                  return reportFilePath;
134              }
135              catch (Exception ex)
136              {
137                  Logger.LogError("Failed to store exception report", ex);
138                  return null;
139              }
140          }
141      }
142  
143      public void Dispose()
144      {
145          _app?.UnhandledException -= App_UnhandledException;
146          TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
147          AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
148      }
149  
150      private enum Context
151      {
152          Unknown = 0,
153          MainThreadException,
154          BackgroundThreadException,
155          UnobservedTaskException,
156          AppDomainUnhandledException,
157      }
158  
159      /// <summary>
160      /// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
161      /// (what to log, what to show to the user, and where to store reports).
162      /// </summary>
163      internal sealed record Options
164      {
165          /// <summary>
166          /// Gets the default configuration.
167          /// </summary>
168          public static Options Default { get; } = new();
169  
170          /// <summary>
171          /// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
172          /// </summary>
173          public bool RedactPii { get; init; } = true;
174  
175          /// <summary>
176          /// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
177          /// </summary>
178          public bool StoreReportOnUserDesktop { get; init; }
179      }
180  }