Skip to content

Commit 437ed90

Browse files
committed
Add system notifications when a model system finishes.
1 parent af87a8b commit 437ed90

2 files changed

Lines changed: 148 additions & 6 deletions

File tree

src/XTMF2.GUI/SystemAlert.cs

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ You should have received a copy of the GNU General Public License
1919
using System;
2020
using System.Diagnostics;
2121
using System.Runtime.InteropServices;
22+
using System.Text;
2223
using XTMF2.GUI.Properties;
2324

2425
namespace XTMF2.GUI;
2526

2627
/// <summary>
27-
/// Plays OS-native audio alerts without requiring any extra NuGet packages.
28+
/// Plays OS-native audio alerts and sends desktop notifications without requiring
29+
/// any extra NuGet packages.
2830
/// All methods are fire-and-forget; failures are silently swallowed so a missing
29-
/// audio device or absent sound file never crashes the application.
31+
/// audio device, notification daemon, or absent sound file never crashes the application.
3032
/// </summary>
3133
internal static class SystemAlert
3234
{
@@ -76,19 +78,151 @@ public static void PlayError()
7678

7779
// ── helpers ───────────────────────────────────────────────────────────
7880

81+
/// <summary>
82+
/// Shows a desktop system notification that a run finished successfully.
83+
/// Fire-and-forget; failures are silently swallowed.
84+
/// </summary>
85+
public static void ShowRunFinished(string runName)
86+
=> ShowNotification("XTMF2", $"Run '{runName}' finished successfully.");
87+
88+
/// <summary>
89+
/// Shows a desktop system notification that a run encountered an error.
90+
/// Fire-and-forget; failures are silently swallowed.
91+
/// </summary>
92+
public static void ShowRunFailed(string runName)
93+
=> ShowNotification("XTMF2", $"Run '{runName}' encountered an error.");
94+
95+
private static void ShowNotification(string title, string message)
96+
{
97+
try
98+
{
99+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
100+
ShowWindowsToast(title, message);
101+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
102+
TryLaunch("osascript",
103+
$"-e 'display notification \"{Escape(message)}\" with title \"{Escape(title)}\"'");
104+
else
105+
ShowLinuxNotification(title, message);
106+
}
107+
catch { /* never crash on a missing notification daemon */ }
108+
}
109+
110+
/// <summary>
111+
/// Sends a Linux/BSD desktop notification.
112+
/// Tries tools in order, stopping at the first that succeeds (exit code 0).
113+
/// <list type="number">
114+
/// <item><c>gdbus call</c> – part of GLib (<c>libglib2.0-bin</c>); handles the
115+
/// <c>a{sv}</c> hints dict correctly via GVariant text format. Present on
116+
/// KDE, GNOME, and most other desktops.</item>
117+
/// <item><c>notify-send</c> – fallback for systems that have <c>libnotify-bin</c>
118+
/// but not <c>gdbus</c>.</item>
119+
/// </list>
120+
/// See: https://specifications.freedesktop.org/notification-spec/latest/
121+
/// </summary>
122+
private static void ShowLinuxNotification(string title, string message)
123+
{
124+
// gdbus call with GVariant text format:
125+
// "[]" → empty as (array of strings, actions)
126+
// "{}" → empty a{sv} (hints dict) — cannot be expressed with dbus-send
127+
// 7000 → expire timeout in ms
128+
var gdbusCmdArgs = string.Join(" ",
129+
"call --session",
130+
"--dest org.freedesktop.Notifications",
131+
"--object-path /org/freedesktop/Notifications",
132+
"--method org.freedesktop.Notifications.Notify",
133+
$"\"{Escape(title)}\"",
134+
"0",
135+
"\"dialog-information\"",
136+
$"\"{Escape(title)}\"",
137+
$"\"{Escape(message)}\"",
138+
"\"[]\"",
139+
"\"{}\"",
140+
"7000");
141+
142+
if (!TryLaunchAndWait("gdbus", gdbusCmdArgs))
143+
TryLaunchAndWait("notify-send",
144+
$"--icon=dialog-information \"{Escape(title)}\" \"{Escape(message)}\"");
145+
}
146+
147+
/// <summary>
148+
/// Sends a Windows 10/11 toast notification via PowerShell's WinRT bindings.
149+
/// Uses <c>-EncodedCommand</c> (Base64 UTF-16LE) so the run name never needs
150+
/// to be shell-quoted.
151+
/// </summary>
152+
private static void ShowWindowsToast(string title, string message)
153+
{
154+
// Escape XML special characters so the inline XML literal is valid.
155+
var xmlTitle = XmlEscape(title);
156+
var xmlMessage = XmlEscape(message);
157+
158+
var script = $"""
159+
$xml = [Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlDocument,ContentType=WindowsRuntime]::new()
160+
$xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text>{xmlTitle}</text><text>{xmlMessage}</text></binding></visual></toast>')
161+
$toast = [Windows.UI.Notifications.ToastNotification,Windows.UI.Notifications,ContentType=WindowsRuntime]::new($xml)
162+
[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]::CreateToastNotifier('XTMF2').Show($toast)
163+
""";
164+
// Encode as UTF-16LE, Base64 — bypasses all shell-quoting issues.
165+
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
166+
167+
// Prefer pwsh (PowerShell 7+); fall back to the inbox powershell.exe.
168+
if (!TryLaunch("pwsh", $"-NonInteractive -WindowStyle Hidden -EncodedCommand {encoded}"))
169+
TryLaunch("powershell", $"-NonInteractive -WindowStyle Hidden -EncodedCommand {encoded}");
170+
}
171+
172+
private static string XmlEscape(string s) => s
173+
.Replace("&", "&amp;")
174+
.Replace("<", "&lt;")
175+
.Replace(">", "&gt;")
176+
.Replace("'", "&apos;")
177+
.Replace("\"", "&quot;");
178+
179+
/// <summary>Escapes double-quotes and backslashes for embedding in a shell argument.</summary>
180+
private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
181+
182+
// ── process helpers ───────────────────────────────────────────────────
183+
184+
/// <summary>
185+
/// Launches <paramref name="exe"/> fire-and-forget.
186+
/// Returns <c>true</c> if the process started; does not check the exit code.
187+
/// Use for audio playback where we never need to fall back on failure.
188+
/// </summary>
79189
private static bool TryLaunch(string exe, string args)
80190
{
81191
try { LaunchDetached(exe, args); return true; }
82192
catch { return false; }
83193
}
84194

195+
/// <summary>
196+
/// Launches <paramref name="exe"/>, waits up to 3 s for it to finish, and returns
197+
/// <c>true</c> only when the process exits with code 0.
198+
/// Use for notification commands where we need to try the next fallback on failure.
199+
/// </summary>
200+
private static bool TryLaunchAndWait(string exe, string args)
201+
{
202+
try
203+
{
204+
var psi = new ProcessStartInfo(exe, args)
205+
{
206+
UseShellExecute = false,
207+
RedirectStandardOutput = true, // suppress gdbus return-value output
208+
RedirectStandardError = true, // suppress error text
209+
CreateNoWindow = true,
210+
};
211+
using var proc = Process.Start(psi);
212+
if (proc is null) return false;
213+
proc.WaitForExit(3000);
214+
return proc.ExitCode == 0;
215+
}
216+
catch { return false; }
217+
}
218+
85219
private static void LaunchDetached(string exe, string args)
86220
{
87221
var psi = new ProcessStartInfo(exe, args)
88222
{
89223
UseShellExecute = false,
90-
RedirectStandardOutput = false,
91-
RedirectStandardError = false,
224+
RedirectStandardOutput = true, // suppress any incidental output
225+
RedirectStandardError = true,
92226
CreateNoWindow = true,
93227
};
94228
using var proc = Process.Start(psi);

src/XTMF2.GUI/ViewModels/RunsViewModel.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ internal void NotifyFinished(string runId)
7373
{
7474
var vm = FindRun(runId);
7575
if (vm is null) return;
76-
Dispatcher.UIThread.Post(() => vm.MarkFinished());
76+
Dispatcher.UIThread.Post(() =>
77+
{
78+
vm.MarkFinished();
79+
SystemAlert.ShowRunFinished(vm.RunName);
80+
});
7781
}
7882

7983
/// <summary>
@@ -83,7 +87,11 @@ internal void NotifyError(string runId, string errorMessage, string stack)
8387
{
8488
var vm = FindRun(runId);
8589
if (vm is null) return;
86-
Dispatcher.UIThread.Post(() => vm.MarkError(errorMessage, stack));
90+
Dispatcher.UIThread.Post(() =>
91+
{
92+
vm.MarkError(errorMessage, stack);
93+
SystemAlert.ShowRunFailed(vm.RunName);
94+
});
8795
}
8896

8997
/// <summary>

0 commit comments

Comments
 (0)