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 }