@@ -19,14 +19,16 @@ You should have received a copy of the GNU General Public License
1919using System ;
2020using System . Diagnostics ;
2121using System . Runtime . InteropServices ;
22+ using System . Text ;
2223using XTMF2 . GUI . Properties ;
2324
2425namespace 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>
3133internal 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 ( "&" , "&" )
174+ . Replace ( "<" , "<" )
175+ . Replace ( ">" , ">" )
176+ . Replace ( "'" , "'" )
177+ . Replace ( "\" " , """ ) ;
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 ) ;
0 commit comments