// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.Common.Services.Reports;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
using SystemUnhandledExceptionEventArgs = System.UnhandledExceptionEventArgs;
using XamlUnhandledExceptionEventArgs = Microsoft.UI.Xaml.UnhandledExceptionEventArgs;
namespace Microsoft.CmdPal.UI.Helpers;
///
/// Global error handler for Command Palette.
///
internal sealed partial class GlobalErrorHandler : IDisposable
{
private ErrorReportBuilder? _errorReportBuilder;
private Options? _options;
private App? _app;
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
internal void Register(App app, Options options, IApplicationInfoService? appInfoService = null)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
_options = options;
_app = app;
_errorReportBuilder = new ErrorReportBuilder(appInfoService);
_app.UnhandledException += App_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
}
private void App_UnhandledException(object sender, XamlUnhandledExceptionEventArgs e)
{
// Exceptions thrown on the main UI thread are handled here.
if (e.Exception != null)
{
HandleException(e.Exception, Context.MainThreadException);
}
}
private void CurrentDomain_UnhandledException(object sender, SystemUnhandledExceptionEventArgs e)
{
// Exceptions thrown on background threads are handled here.
if (e.ExceptionObject is Exception ex)
{
HandleException(ex, Context.AppDomainUnhandledException);
}
}
private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
// This event is raised only when a faulted Task is garbage-collected
// without its exception being observed. It is NOT raised immediately
// when the Task faults; timing depends on GC finalization.
e.SetObserved();
HandleException(e.Exception, Context.UnobservedTaskException);
}
private void HandleException(Exception ex, Context context)
{
Logger.LogError($"Unhandled exception detected ({context})", ex);
if (context == Context.MainThreadException)
{
var report = _errorReportBuilder!.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
string message;
string caption;
try
{
message = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Message");
caption = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Caption");
}
catch
{
// The resource loader may not be available if the exception occurred during startup.
// Fall back to hardcoded strings in that case.
message = "Command Palette has encountered a fatal error and must close.";
caption = "Command Palette - Fatal error";
}
PInvoke.MessageBox(
HWND.Null,
message,
caption,
MESSAGEBOX_STYLE.MB_ICONERROR);
}
}
private static string? StoreReport(string report, bool storeOnDesktop)
{
// Generate a unique name for the report file; include timestamp and a random zero-padded number to avoid collisions
// in case of crash storm.
var name = FormattableString.Invariant($"CmdPal_ErrorReport_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{Random.Shared.Next(100000):D5}.log");
// Always store a copy in log directory, this way it is available for Bug Report Tool
string? reportPath = null;
if (Logger.CurrentVersionLogDirectoryPath != null)
{
reportPath = Save(report, name, static () => Logger.CurrentVersionLogDirectoryPath);
}
// Optionally store a copy on the desktop for user (in)convenience
if (storeOnDesktop)
{
var path = Save(report, name, static () => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory));
// show the desktop copy if both succeeded
if (path != null)
{
reportPath = path;
}
}
return reportPath;
static string? Save(string reportContent, string reportFileName, Func directory)
{
try
{
var logDirectory = directory();
Directory.CreateDirectory(logDirectory);
var reportFilePath = Path.Combine(logDirectory, reportFileName);
File.WriteAllText(reportFilePath, reportContent);
return reportFilePath;
}
catch (Exception ex)
{
Logger.LogError("Failed to store exception report", ex);
return null;
}
}
}
public void Dispose()
{
_app?.UnhandledException -= App_UnhandledException;
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
}
private enum Context
{
Unknown = 0,
MainThreadException,
BackgroundThreadException,
UnobservedTaskException,
AppDomainUnhandledException,
}
///
/// Configuration options controlling how reacts to exceptions
/// (what to log, what to show to the user, and where to store reports).
///
internal sealed record Options
{
///
/// Gets the default configuration.
///
public static Options Default { get; } = new();
///
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
///
public bool RedactPii { get; init; } = true;
///
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
///
public bool StoreReportOnUserDesktop { get; init; }
}
}