diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 0db1614671..1173920340 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -26,6 +26,16 @@ namespace ManagedCommon private static readonly string Version = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Version ?? "Unknown"; + /// + /// Gets the path to the log directory for the current version of the app. + /// + public static string CurrentVersionLogDirectoryPath { get; private set; } + + /// + /// Gets the path to the log directory for the app. + /// + public static string AppLogDirectoryPath { get; private set; } + /// /// Initializes the logger and sets the path for logging. /// @@ -42,6 +52,9 @@ namespace ManagedCommon Directory.CreateDirectory(versionedPath); } + AppLogDirectoryPath = basePath; + CurrentVersionLogDirectoryPath = versionedPath; + var logFilePath = Path.Combine(versionedPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 9ba41a08fb..917716be19 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -40,6 +40,8 @@ namespace Microsoft.CmdPal.UI; /// public partial class App : Application { + private readonly GlobalErrorHandler _globalErrorHandler = new(); + /// /// Gets the current instance in use. /// @@ -61,6 +63,10 @@ public partial class App : Application /// public App() { +#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER + _globalErrorHandler.Register(this); +#endif + Services = ConfigureServices(); this.InitializeComponent(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs new file mode 100644 index 0000000000..0b55b03615 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs @@ -0,0 +1,134 @@ +// 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.Helpers; +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 +{ + // GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available. + internal void Register(App app) + { + ArgumentNullException.ThrowIfNull(app); + + 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, isRecoverable: true); + } + + private void HandleException(Exception ex, Context context, bool isRecoverable = false) + { + Logger.LogError($"Unhandled exception detected ({context})", ex); + + if (context == Context.MainThreadException) + { + var error = DiagnosticsHelper.BuildExceptionMessage(ex, null); + var report = $""" + This is an error report generated by Windows Command Palette. + If you are seeing this message, it means the application has encountered an unexpected issue. + You can help us fix it by filing a report at https://aka.ms/powerToysReportBug. + {error} + """; + + StoreReport(report, storeOnDesktop: false); + + PInvoke.MessageBox( + HWND.Null, + "Command Palette has encountered a fatal error and must close.\n\nAn error report has been saved to your desktop.", + "Unhandled Error", + 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; + } + } + } + + private enum Context + { + Unknown = 0, + MainThreadException, + BackgroundThreadException, + UnobservedTaskException, + AppDomainUnhandledException, + } +}