// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Text; using System.Collections.Concurrent; namespace Microsoft.Maui.Platform.Linux.Services; /// /// Linux notification service using notify-send (libnotify) or D-Bus directly. /// Supports interactive notifications with action callbacks. /// public class NotificationService { private readonly string _appName; private readonly string? _defaultIconPath; private readonly ConcurrentDictionary _activeNotifications = new(); private static uint _notificationIdCounter = 1; private Process? _dBusMonitor; private bool _monitoringActions; /// /// Event raised when a notification action is invoked. /// public event EventHandler? ActionInvoked; /// /// Event raised when a notification is closed. /// public event EventHandler? NotificationClosed; public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null) { _appName = appName; _defaultIconPath = defaultIconPath; } /// /// Starts monitoring for notification action callbacks via D-Bus. /// Call this once at application startup if you want to receive action callbacks. /// public void StartActionMonitoring() { if (_monitoringActions) return; _monitoringActions = true; // Start D-Bus monitor for notification signals Task.Run(MonitorNotificationSignals); } /// /// Stops monitoring for notification action callbacks. /// public void StopActionMonitoring() { _monitoringActions = false; try { _dBusMonitor?.Kill(); _dBusMonitor?.Dispose(); _dBusMonitor = null; } catch { } } private async Task MonitorNotificationSignals() { try { var startInfo = new ProcessStartInfo { FileName = "dbus-monitor", Arguments = "--session \"interface='org.freedesktop.Notifications'\"", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; _dBusMonitor = Process.Start(startInfo); if (_dBusMonitor == null) return; var reader = _dBusMonitor.StandardOutput; var buffer = new StringBuilder(); while (_monitoringActions && !_dBusMonitor.HasExited) { var line = await reader.ReadLineAsync(); if (line == null) break; buffer.AppendLine(line); // Look for ActionInvoked or NotificationClosed signals if (line.Contains("ActionInvoked")) { await ProcessActionInvoked(reader); } else if (line.Contains("NotificationClosed")) { await ProcessNotificationClosed(reader); } } } catch (Exception ex) { Console.WriteLine($"[NotificationService] D-Bus monitor error: {ex.Message}"); } } private async Task ProcessActionInvoked(StreamReader reader) { try { // Read the signal data (notification id and action key) uint notificationId = 0; string? actionKey = null; for (int i = 0; i < 10; i++) // Read a few lines to get the data { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.Contains("uint32")) { var idMatch = System.Text.RegularExpressions.Regex.Match(line, @"uint32\s+(\d+)"); if (idMatch.Success) { notificationId = uint.Parse(idMatch.Groups[1].Value); } } else if (line.Contains("string")) { var strMatch = System.Text.RegularExpressions.Regex.Match(line, @"string\s+""([^""]*)"""); if (strMatch.Success && actionKey == null) { actionKey = strMatch.Groups[1].Value; } } if (notificationId > 0 && actionKey != null) break; } if (notificationId > 0 && actionKey != null) { if (_activeNotifications.TryGetValue(notificationId, out var context)) { // Invoke callback if registered if (context.ActionCallbacks?.TryGetValue(actionKey, out var callback) == true) { callback?.Invoke(); } ActionInvoked?.Invoke(this, new NotificationActionEventArgs(notificationId, actionKey, context.Tag)); } } } catch { } } private async Task ProcessNotificationClosed(StreamReader reader) { try { uint notificationId = 0; uint reason = 0; for (int i = 0; i < 5; i++) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.Contains("uint32")) { var match = System.Text.RegularExpressions.Regex.Match(line, @"uint32\s+(\d+)"); if (match.Success) { if (notificationId == 0) notificationId = uint.Parse(match.Groups[1].Value); else reason = uint.Parse(match.Groups[1].Value); } } } if (notificationId > 0) { _activeNotifications.TryRemove(notificationId, out var context); NotificationClosed?.Invoke(this, new NotificationClosedEventArgs( notificationId, (NotificationCloseReason)reason, context?.Tag)); } } catch { } } /// /// Shows a simple notification. /// public async Task ShowAsync(string title, string message) { await ShowAsync(new NotificationOptions { Title = title, Message = message }); } /// /// Shows a notification with action buttons and callbacks. /// /// Notification title. /// Notification message. /// List of action buttons with callbacks. /// Optional tag to identify the notification in events. /// The notification ID. public async Task ShowWithActionsAsync( string title, string message, IEnumerable actions, string? tag = null) { var notificationId = _notificationIdCounter++; // Store context for callbacks var context = new NotificationContext { Tag = tag, ActionCallbacks = actions.ToDictionary(a => a.Key, a => a.Callback) }; _activeNotifications[notificationId] = context; // Build actions dictionary for options var actionDict = actions.ToDictionary(a => a.Key, a => a.Label); await ShowAsync(new NotificationOptions { Title = title, Message = message, Actions = actionDict }); return notificationId; } /// /// Cancels/closes an active notification. /// public async Task CancelAsync(uint notificationId) { try { // Use gdbus to close the notification var startInfo = new ProcessStartInfo { FileName = "gdbus", Arguments = $"call --session --dest org.freedesktop.Notifications " + $"--object-path /org/freedesktop/Notifications " + $"--method org.freedesktop.Notifications.CloseNotification {notificationId}", UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process != null) { await process.WaitForExitAsync(); } _activeNotifications.TryRemove(notificationId, out _); } catch { } } /// /// Shows a notification with options. /// public async Task ShowAsync(NotificationOptions options) { try { var args = BuildNotifyArgs(options); var startInfo = new ProcessStartInfo { FileName = "notify-send", Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process != null) { await process.WaitForExitAsync(); } } catch (Exception ex) { // Fall back to zenity notification await TryZenityNotification(options); } } private string BuildNotifyArgs(NotificationOptions options) { var args = new List(); // App name args.Add($"--app-name=\"{EscapeArg(_appName)}\""); // Urgency args.Add($"--urgency={options.Urgency.ToString().ToLower()}"); // Expire time (milliseconds, 0 = never expire) if (options.ExpireTimeMs > 0) { args.Add($"--expire-time={options.ExpireTimeMs}"); } // Icon var icon = options.IconPath ?? _defaultIconPath; if (!string.IsNullOrEmpty(icon)) { args.Add($"--icon=\"{EscapeArg(icon)}\""); } else if (!string.IsNullOrEmpty(options.IconName)) { args.Add($"--icon={options.IconName}"); } // Category if (!string.IsNullOrEmpty(options.Category)) { args.Add($"--category={options.Category}"); } // Hint for transient notifications if (options.IsTransient) { args.Add("--hint=int:transient:1"); } // Actions (if supported) if (options.Actions?.Count > 0) { foreach (var action in options.Actions) { args.Add($"--action=\"{action.Key}={EscapeArg(action.Value)}\""); } } // Title and message args.Add($"\"{EscapeArg(options.Title)}\""); args.Add($"\"{EscapeArg(options.Message)}\""); return string.Join(" ", args); } private async Task TryZenityNotification(NotificationOptions options) { try { var iconArg = ""; if (!string.IsNullOrEmpty(options.IconPath)) { iconArg = $"--window-icon=\"{options.IconPath}\""; } var typeArg = options.Urgency == NotificationUrgency.Critical ? "--error" : "--info"; var startInfo = new ProcessStartInfo { FileName = "zenity", Arguments = $"{typeArg} {iconArg} --title=\"{EscapeArg(options.Title)}\" --text=\"{EscapeArg(options.Message)}\" --timeout=5", UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process != null) { await process.WaitForExitAsync(); } } catch { // Silently fail if no notification method available } } /// /// Checks if notifications are available on this system. /// public static bool IsAvailable() { try { var startInfo = new ProcessStartInfo { FileName = "which", Arguments = "notify-send", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) return false; process.WaitForExit(); return process.ExitCode == 0; } catch { return false; } } private static string EscapeArg(string arg) { return arg?.Replace("\\", "\\\\").Replace("\"", "\\\"") ?? ""; } } /// /// Options for displaying a notification. /// public class NotificationOptions { public string Title { get; set; } = ""; public string Message { get; set; } = ""; public string? IconPath { get; set; } public string? IconName { get; set; } // Standard icon name like "dialog-information" public NotificationUrgency Urgency { get; set; } = NotificationUrgency.Normal; public int ExpireTimeMs { get; set; } = 5000; // 5 seconds default public string? Category { get; set; } // e.g., "email", "im", "transfer" public bool IsTransient { get; set; } public Dictionary? Actions { get; set; } } /// /// Notification urgency level. /// public enum NotificationUrgency { Low, Normal, Critical } /// /// Reason a notification was closed. /// public enum NotificationCloseReason { Expired = 1, Dismissed = 2, Closed = 3, Undefined = 4 } /// /// Internal context for tracking active notifications. /// internal class NotificationContext { public string? Tag { get; set; } public Dictionary? ActionCallbacks { get; set; } } /// /// Event args for notification action events. /// public class NotificationActionEventArgs : EventArgs { public uint NotificationId { get; } public string ActionKey { get; } public string? Tag { get; } public NotificationActionEventArgs(uint notificationId, string actionKey, string? tag) { NotificationId = notificationId; ActionKey = actionKey; Tag = tag; } } /// /// Event args for notification closed events. /// public class NotificationClosedEventArgs : EventArgs { public uint NotificationId { get; } public NotificationCloseReason Reason { get; } public string? Tag { get; } public NotificationClosedEventArgs(uint notificationId, NotificationCloseReason reason, string? tag) { NotificationId = notificationId; Reason = reason; Tag = tag; } } /// /// Defines an action button for a notification. /// public class NotificationAction { /// /// Internal action key (not displayed). /// public string Key { get; set; } = ""; /// /// Display label for the action button. /// public string Label { get; set; } = ""; /// /// Callback to invoke when the action is clicked. /// public Action? Callback { get; set; } public NotificationAction() { } public NotificationAction(string key, string label, Action? callback = null) { Key = key; Label = label; Callback = callback; } }