Skip to content

Commit 05fe517

Browse files
committed
Add crash reporting for notification failures
1 parent f863c34 commit 05fe517

5 files changed

Lines changed: 144 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@
4141
- Prefer the smallest clean change for each step.
4242
- Use local prototypes to validate risky platform behavior before building larger abstractions.
4343
- Use `scripts/run-dev.ps1` as the simple local run entrypoint and `scripts/run-build.ps1` as the simple build entrypoint.
44+
- Current dev and release builds share the same singleton mutex, AppUserModelID, Start Menu shortcut, settings path, and startup Run key. Do not assume they can run side by side until a separate dev identity is intentionally added.
4445
- Do not introduce unrelated product scope such as cloud sync, accounts, or cross-platform support unless explicitly requested.
4546
- Do not run builds, tests, browser checks, or packaging commands by default after normal edits unless the user asks or the change is high-risk.

App.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public partial class App : System.Windows.Application
1717

1818
protected override void OnStartup(StartupEventArgs e)
1919
{
20+
CrashReporter.Install(this);
2021
base.OnStartup(e);
2122

2223
singleInstanceGuard = new SingleInstanceGuard();

CrashReporter.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.IO;
2+
using System.Runtime.ExceptionServices;
3+
using System.Text;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using System.Windows;
7+
8+
namespace ToastDesk;
9+
10+
public static class CrashReporter
11+
{
12+
private static int isShowingCrash;
13+
14+
public static void Install(System.Windows.Application application)
15+
{
16+
application.DispatcherUnhandledException += (_, args) =>
17+
{
18+
ShowFatalException(args.Exception);
19+
args.Handled = true;
20+
application.Shutdown(-1);
21+
};
22+
23+
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
24+
{
25+
var exception = args.ExceptionObject as Exception
26+
?? new InvalidOperationException($"Unhandled non-Exception object: {args.ExceptionObject}");
27+
ShowFatalException(exception);
28+
};
29+
30+
TaskScheduler.UnobservedTaskException += (_, args) =>
31+
{
32+
ShowFatalException(args.Exception);
33+
args.SetObserved();
34+
application.Dispatcher.InvokeAsync(() => application.Shutdown(-1));
35+
};
36+
}
37+
38+
public static string FormatExceptionSummary(Exception exception)
39+
{
40+
var exceptionInfo = ExceptionDispatchInfo.Capture(exception).SourceException;
41+
return $"{exceptionInfo.GetType().FullName} (HRESULT 0x{exceptionInfo.HResult:X8}): {exceptionInfo.Message}";
42+
}
43+
44+
private static void ShowFatalException(Exception exception)
45+
{
46+
if (Interlocked.Exchange(ref isShowingCrash, 1) == 1)
47+
{
48+
return;
49+
}
50+
51+
try
52+
{
53+
WriteCrashLog(exception);
54+
System.Windows.MessageBox.Show(
55+
BuildCrashMessage(exception),
56+
"ToastDesk crashed",
57+
MessageBoxButton.OK,
58+
MessageBoxImage.Error,
59+
MessageBoxResult.OK,
60+
System.Windows.MessageBoxOptions.DefaultDesktopOnly);
61+
}
62+
finally
63+
{
64+
Interlocked.Exchange(ref isShowingCrash, 0);
65+
}
66+
}
67+
68+
private static string BuildCrashMessage(Exception exception)
69+
{
70+
var builder = new StringBuilder();
71+
builder.AppendLine("ToastDesk hit an unhandled error and must close.");
72+
builder.AppendLine();
73+
builder.AppendLine(FormatExceptionSummary(exception));
74+
builder.AppendLine();
75+
builder.AppendLine($"Source: {exception.Source ?? "Unknown"}");
76+
builder.AppendLine($"Target: {exception.TargetSite?.Name ?? "Unknown"}");
77+
builder.AppendLine();
78+
builder.AppendLine("Stack trace:");
79+
builder.AppendLine(exception.ToString());
80+
builder.AppendLine();
81+
builder.AppendLine($"A copy was written to: {GetCrashLogPath()}");
82+
return builder.ToString();
83+
}
84+
85+
private static void WriteCrashLog(Exception exception)
86+
{
87+
var logPath = GetCrashLogPath();
88+
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
89+
File.WriteAllText(
90+
logPath,
91+
$"{DateTimeOffset.Now:O}{Environment.NewLine}{BuildCrashMessage(exception)}");
92+
}
93+
94+
private static string GetCrashLogPath()
95+
{
96+
return Path.Combine(
97+
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
98+
"ToastDesk",
99+
"crash-last.log");
100+
}
101+
}

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,25 @@ The local packages are written to `artifacts/release`.
7272
powershell -ExecutionPolicy Bypass -File .\scripts\run-dev.ps1
7373
```
7474

75+
## Local Testing With An Installed Release
76+
77+
The installed release and a local dev build currently use the same ToastDesk identity. They share the single-instance mutex, AppUserModelID, Start Menu shortcut, settings folder, and Windows startup Run key.
78+
79+
You do not need to uninstall the released app for normal local testing, but you should exit ToastDesk from the tray before starting a dev build. If **Start with Windows** is enabled while running a dev build, the startup entry can point at the dev executable. Turn that setting off for dev testing, or turn it back on from the installed release afterward.
80+
81+
For installer-like testing on the same PC:
82+
83+
1. Exit the installed ToastDesk tray app.
84+
2. Build a portable release with:
85+
86+
```powershell
87+
powershell -ExecutionPolicy Bypass -File .\scripts\publish-release.ps1 -SkipInstallers
88+
```
89+
90+
3. Run `artifacts\publish\win-x64\ToastDesk.exe`.
91+
92+
For true side-by-side testing while the released app keeps running in the background, use a VM/Windows Sandbox or add a separate dev identity first.
93+
7594
## Build
7695

7796
```powershell

WindowsNotificationListener.cs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -239,19 +239,30 @@ private async Task<IReadOnlyList<UserNotification>> GetCurrentNotificationsAsync
239239

240240
private static NotificationDetails ExtractDetails(UserNotification notification)
241241
{
242-
var binding = notification.Notification.Visual.GetBinding(KnownNotificationBindings.ToastGeneric);
243-
var textElements = binding?.GetTextElements().Select(item => item.Text).Where(text => !string.IsNullOrWhiteSpace(text)).ToArray() ?? [];
244-
var sourceAppName = notification.AppInfo?.DisplayInfo.DisplayName;
245-
var sourceAppUserModelId = notification.AppInfo?.AppUserModelId;
246-
247-
var (title, message) = textElements.Length switch
242+
try
248243
{
249-
0 => ("Windows notification", $"Notification ID {notification.Id}"),
250-
1 => (textElements[0], $"Notification ID {notification.Id}"),
251-
_ => (textElements[0], string.Join(Environment.NewLine, textElements.Skip(1)))
252-
};
244+
var binding = notification.Notification.Visual.GetBinding(KnownNotificationBindings.ToastGeneric);
245+
var textElements = binding?.GetTextElements().Select(item => item.Text).Where(text => !string.IsNullOrWhiteSpace(text)).ToArray() ?? [];
246+
var sourceAppName = notification.AppInfo?.DisplayInfo.DisplayName;
247+
var sourceAppUserModelId = notification.AppInfo?.AppUserModelId;
248+
249+
var (title, message) = textElements.Length switch
250+
{
251+
0 => ("Windows notification", $"Notification ID {notification.Id}"),
252+
1 => (textElements[0], $"Notification ID {notification.Id}"),
253+
_ => (textElements[0], string.Join(Environment.NewLine, textElements.Skip(1)))
254+
};
253255

254-
return new NotificationDetails(title, message, sourceAppName, sourceAppUserModelId);
256+
return new NotificationDetails(title, message, sourceAppName, sourceAppUserModelId);
257+
}
258+
catch (Exception ex) when (ex is COMException or InvalidOperationException or NullReferenceException)
259+
{
260+
return new NotificationDetails(
261+
"Unsupported Windows notification skipped",
262+
$"ToastDesk could not read notification ID {notification.Id}.{Environment.NewLine}{CrashReporter.FormatExceptionSummary(ex)}",
263+
null,
264+
null);
265+
}
255266
}
256267

257268
private sealed record NotificationDetails(

0 commit comments

Comments
 (0)