diff --git a/src/XTMF2.GUI/Properties/Settings.cs b/src/XTMF2.GUI/Properties/Settings.cs
index 41a02f8..b97031f 100644
--- a/src/XTMF2.GUI/Properties/Settings.cs
+++ b/src/XTMF2.GUI/Properties/Settings.cs
@@ -69,6 +69,11 @@ public Settings()
public string? Theme { get; set; } = "Dark";
public string? Language { get; set; } = "en";
+ ///
+ /// When true the application will play a system sound for error toasts.
+ /// Defaults to false so the feature is opt-in.
+ ///
+ public bool PlaySystemSounds { get; set; } = false;
public void Save()
{
@@ -105,6 +110,7 @@ private static Settings Load()
{
settings.Theme = loaded.Theme;
settings.Language = loaded.Language;
+ settings.PlaySystemSounds = loaded.PlaySystemSounds;
}
}
}
diff --git a/src/XTMF2.GUI/Resources/Strings.Designer.cs b/src/XTMF2.GUI/Resources/Strings.Designer.cs
index b718043..be171b6 100644
--- a/src/XTMF2.GUI/Resources/Strings.Designer.cs
+++ b/src/XTMF2.GUI/Resources/Strings.Designer.cs
@@ -120,6 +120,7 @@ public static string Format(string key, params object[] args)
public static string Settings_Runtime => Get(nameof(Settings_Runtime));
public static string Settings_VerboseLogging => Get(nameof(Settings_VerboseLogging));
public static string Settings_AutoSave => Get(nameof(Settings_AutoSave));
+ public static string Settings_PlaySystemSounds => Get(nameof(Settings_PlaySystemSounds));
public static string Settings_Cancel => Get(nameof(Settings_Cancel));
public static string Settings_Save => Get(nameof(Settings_Save));
public static string Settings_Language => Get(nameof(Settings_Language));
diff --git a/src/XTMF2.GUI/Resources/Strings.es.resx b/src/XTMF2.GUI/Resources/Strings.es.resx
index c8b6c4e..e138631 100644
--- a/src/XTMF2.GUI/Resources/Strings.es.resx
+++ b/src/XTMF2.GUI/Resources/Strings.es.resx
@@ -280,6 +280,9 @@ Esta acción no se puede deshacer.
Guardar proyectos automáticamente
+
+ Reproducir sonidos del sistema para alertas de error
+
Cancelar
diff --git a/src/XTMF2.GUI/Resources/Strings.fr.resx b/src/XTMF2.GUI/Resources/Strings.fr.resx
index 7f49784..c375efc 100644
--- a/src/XTMF2.GUI/Resources/Strings.fr.resx
+++ b/src/XTMF2.GUI/Resources/Strings.fr.resx
@@ -286,6 +286,9 @@ Cette action ne peut pas être annulée.
Sauvegarde automatique des projets
+
+ Jouer les sons système pour les alertes d’erreur
+
Annuler
diff --git a/src/XTMF2.GUI/Resources/Strings.resx b/src/XTMF2.GUI/Resources/Strings.resx
index 4a075f5..1da02a0 100644
--- a/src/XTMF2.GUI/Resources/Strings.resx
+++ b/src/XTMF2.GUI/Resources/Strings.resx
@@ -295,6 +295,9 @@ This action cannot be undone.
Auto-save projects
+
+ Play system sounds for error alerts
+
Cancel
diff --git a/src/XTMF2.GUI/SystemAlert.cs b/src/XTMF2.GUI/SystemAlert.cs
new file mode 100644
index 0000000..1f71ec2
--- /dev/null
+++ b/src/XTMF2.GUI/SystemAlert.cs
@@ -0,0 +1,231 @@
+/*
+ Copyright 2026 University of Toronto
+
+ This file is part of XTMF2.
+
+ XTMF2 is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ XTMF2 is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with XTMF2. If not, see .
+*/
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using XTMF2.GUI.Properties;
+
+namespace XTMF2.GUI;
+
+///
+/// Plays OS-native audio alerts and sends desktop notifications without requiring
+/// any extra NuGet packages.
+/// All methods are fire-and-forget; failures are silently swallowed so a missing
+/// audio device, notification daemon, or absent sound file never crashes the application.
+///
+internal static class SystemAlert
+{
+ // Windows: MB_ICONHAND / MB_ICONSTOP — the standard "error" beep.
+ [DllImport("user32.dll", SetLastError = false)]
+ private static extern bool MessageBeep(uint uType);
+ private const uint MB_ICONHAND = 0x00000010;
+ // MB_OK (0) is the "Default Beep" — the same gentle thud Windows plays
+ // when you press Backspace in an empty field or do something that is simply
+ // not allowed rather than catastrophically wrong.
+ private const uint MB_OK = 0x00000000;
+
+ /// Plays the platform "cannot do that" bell asynchronously and does not block the caller.
+ public static void PlayError()
+ {
+ // Respect the user's opt-in setting; do nothing when sounds are disabled.
+ if (!Properties.Settings.Default.PlaySystemSounds)
+ return;
+
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // MB_OK is the "Default Beep" — the same sound Windows plays when
+ // you press Backspace in an empty text field.
+ MessageBeep(MB_OK);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ // Tink is the standard macOS UI "cannot do that" sound.
+ LaunchDetached("afplay", "/System/Library/Sounds/Tink.aiff");
+ }
+ else
+ {
+ // Linux / BSD: the freedesktop "bell" id is the closest equivalent.
+ // Fall back to the bell OGA file if canberra is unavailable.
+ if (!TryLaunch("canberra-gtk-play", "--id=bell"))
+ TryLaunch("paplay",
+ "/usr/share/sounds/freedesktop/stereo/bell.oga");
+ }
+ }
+ catch
+ {
+ // Never let a missing audio system crash the app.
+ }
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────
+
+ ///
+ /// Shows a desktop system notification that a run finished successfully.
+ /// Fire-and-forget; failures are silently swallowed.
+ ///
+ public static void ShowRunFinished(string runName)
+ => ShowNotification("XTMF2", $"Run '{runName}' finished successfully.");
+
+ ///
+ /// Shows a desktop system notification that a run encountered an error.
+ /// Fire-and-forget; failures are silently swallowed.
+ ///
+ public static void ShowRunFailed(string runName)
+ => ShowNotification("XTMF2", $"Run '{runName}' encountered an error.");
+
+ private static void ShowNotification(string title, string message)
+ {
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ ShowWindowsToast(title, message);
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ TryLaunch("osascript",
+ $"-e 'display notification \"{Escape(message)}\" with title \"{Escape(title)}\"'");
+ else
+ ShowLinuxNotification(title, message);
+ }
+ catch { /* never crash on a missing notification daemon */ }
+ }
+
+ ///
+ /// Sends a Linux/BSD desktop notification.
+ /// Tries tools in order, stopping at the first that succeeds (exit code 0).
+ ///
+ /// - gdbus call – part of GLib (libglib2.0-bin); handles the
+ /// a{sv} hints dict correctly via GVariant text format. Present on
+ /// KDE, GNOME, and most other desktops.
+ /// - notify-send – fallback for systems that have libnotify-bin
+ /// but not gdbus.
+ ///
+ /// See: https://specifications.freedesktop.org/notification-spec/latest/
+ ///
+ private static void ShowLinuxNotification(string title, string message)
+ {
+ // gdbus call with GVariant text format:
+ // "[]" → empty as (array of strings, actions)
+ // "{}" → empty a{sv} (hints dict) — cannot be expressed with dbus-send
+ // 7000 → expire timeout in ms
+ var gdbusCmdArgs = string.Join(" ",
+ "call --session",
+ "--dest org.freedesktop.Notifications",
+ "--object-path /org/freedesktop/Notifications",
+ "--method org.freedesktop.Notifications.Notify",
+ $"\"{Escape(title)}\"",
+ "0",
+ "\"dialog-information\"",
+ $"\"{Escape(title)}\"",
+ $"\"{Escape(message)}\"",
+ "\"[]\"",
+ "\"{}\"",
+ "7000");
+
+ if (!TryLaunchAndWait("gdbus", gdbusCmdArgs))
+ TryLaunchAndWait("notify-send",
+ $"--icon=dialog-information \"{Escape(title)}\" \"{Escape(message)}\"");
+ }
+
+ ///
+ /// Sends a Windows 10/11 toast notification via PowerShell's WinRT bindings.
+ /// Uses -EncodedCommand (Base64 UTF-16LE) so the run name never needs
+ /// to be shell-quoted.
+ ///
+ private static void ShowWindowsToast(string title, string message)
+ {
+ // Escape XML special characters so the inline XML literal is valid.
+ var xmlTitle = XmlEscape(title);
+ var xmlMessage = XmlEscape(message);
+
+ var script = $"""
+$xml = [Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom.XmlDocument,ContentType=WindowsRuntime]::new()
+$xml.LoadXml('{xmlTitle}{xmlMessage}')
+$toast = [Windows.UI.Notifications.ToastNotification,Windows.UI.Notifications,ContentType=WindowsRuntime]::new($xml)
+[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]::CreateToastNotifier('XTMF2').Show($toast)
+""";
+ // Encode as UTF-16LE, Base64 — bypasses all shell-quoting issues.
+ var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
+
+ // Prefer pwsh (PowerShell 7+); fall back to the inbox powershell.exe.
+ if (!TryLaunch("pwsh", $"-NonInteractive -WindowStyle Hidden -EncodedCommand {encoded}"))
+ TryLaunch("powershell", $"-NonInteractive -WindowStyle Hidden -EncodedCommand {encoded}");
+ }
+
+ private static string XmlEscape(string s) => s
+ .Replace("&", "&")
+ .Replace("<", "<")
+ .Replace(">", ">")
+ .Replace("'", "'")
+ .Replace("\"", """);
+
+ /// Escapes double-quotes and backslashes for embedding in a shell argument.
+ private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
+
+ // ── process helpers ───────────────────────────────────────────────────
+
+ ///
+ /// Launches fire-and-forget.
+ /// Returns true if the process started; does not check the exit code.
+ /// Use for audio playback where we never need to fall back on failure.
+ ///
+ private static bool TryLaunch(string exe, string args)
+ {
+ try { LaunchDetached(exe, args); return true; }
+ catch { return false; }
+ }
+
+ ///
+ /// Launches , waits up to 3 s for it to finish, and returns
+ /// true only when the process exits with code 0.
+ /// Use for notification commands where we need to try the next fallback on failure.
+ ///
+ private static bool TryLaunchAndWait(string exe, string args)
+ {
+ try
+ {
+ var psi = new ProcessStartInfo(exe, args)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true, // suppress gdbus return-value output
+ RedirectStandardError = true, // suppress error text
+ CreateNoWindow = true,
+ };
+ using var proc = Process.Start(psi);
+ if (proc is null) return false;
+ proc.WaitForExit(3000);
+ return proc.ExitCode == 0;
+ }
+ catch { return false; }
+ }
+
+ private static void LaunchDetached(string exe, string args)
+ {
+ var psi = new ProcessStartInfo(exe, args)
+ {
+ UseShellExecute = false,
+ RedirectStandardOutput = true, // suppress any incidental output
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+ using var proc = Process.Start(psi);
+ // proc is intentionally not awaited; the sound plays in the background.
+ }
+}
diff --git a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
index 99dd9d9..e3d8a07 100644
--- a/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/ModelSystemEditorViewModel.cs
@@ -1474,8 +1474,11 @@ internal void ShowToast(string message, bool isError = false, int durationMs = 3
_toastCts = new CancellationTokenSource();
var token = _toastCts.Token;
- ToastIsError = isError;
- ToastMessage = message;
+ ToastIsError = isError;
+ ToastMessage = message;
+
+ if (isError)
+ SystemAlert.PlayError();
_ = Task.Delay(durationMs, token).ContinueWith(_ =>
{
diff --git a/src/XTMF2.GUI/ViewModels/RunsViewModel.cs b/src/XTMF2.GUI/ViewModels/RunsViewModel.cs
index a328122..f282f35 100644
--- a/src/XTMF2.GUI/ViewModels/RunsViewModel.cs
+++ b/src/XTMF2.GUI/ViewModels/RunsViewModel.cs
@@ -73,7 +73,11 @@ internal void NotifyFinished(string runId)
{
var vm = FindRun(runId);
if (vm is null) return;
- Dispatcher.UIThread.Post(() => vm.MarkFinished());
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.MarkFinished();
+ SystemAlert.ShowRunFinished(vm.RunName);
+ });
}
///
@@ -83,7 +87,11 @@ internal void NotifyError(string runId, string errorMessage, string stack)
{
var vm = FindRun(runId);
if (vm is null) return;
- Dispatcher.UIThread.Post(() => vm.MarkError(errorMessage, stack));
+ Dispatcher.UIThread.Post(() =>
+ {
+ vm.MarkError(errorMessage, stack);
+ SystemAlert.ShowRunFailed(vm.RunName);
+ });
}
///
diff --git a/src/XTMF2.GUI/Views/SettingsWindow.axaml b/src/XTMF2.GUI/Views/SettingsWindow.axaml
index f377331..474ee37 100644
--- a/src/XTMF2.GUI/Views/SettingsWindow.axaml
+++ b/src/XTMF2.GUI/Views/SettingsWindow.axaml
@@ -103,6 +103,9 @@
+
+
diff --git a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs
index 7fe81da..248ce03 100644
--- a/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs
+++ b/src/XTMF2.GUI/Views/SettingsWindow.axaml.cs
@@ -63,6 +63,9 @@ private void LoadSettings()
{
LanguageComboBox.SelectedItem = languageItem;
}
+
+ // Load system sounds preference
+ PlaySystemSoundsCheckBox.IsChecked = Properties.Settings.Default.PlaySystemSounds;
}
private void ThemeComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -128,9 +131,14 @@ private void SaveSettings()
if (_currentLanguage != null)
{
Properties.Settings.Default.Language = _currentLanguage;
- Properties.Settings.Default.Save();
}
+ // Save system sounds preference
+ Properties.Settings.Default.PlaySystemSounds =
+ PlaySystemSoundsCheckBox.IsChecked == true;
+
+ Properties.Settings.Default.Save();
+
// Theme is already saved via ChangeTheme method
// which calls SaveThemePreference internally
}