Files
logikonline 3412cb982e
All checks were successful
CI / Build (Linux) (push) Successful in 21s
fix(interop): resolve native resource leaks in GTK and WebKit interop
Fix critical memory leaks identified in architecture review: Add signal handler disconnection in WebKitNative (load-changed and script-dialog signals now properly cleaned up), implement GTK idle callback cleanup with automatic removal on completion, add dlclose() calls for WebKit library handles, track GTK signal IDs in GtkSkiaSurfaceWidget for proper disposal. Replace empty catch blocks in GestureManager with logged exception handling. Add WebKitNative.Cleanup() and GtkNative.ClearCallbacks() methods for application shutdown.
2026-03-06 23:14:53 -05:00

428 lines
13 KiB
C#

// 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;
/// <summary>
/// Linux notification service using notify-send (libnotify) or D-Bus directly.
/// Supports interactive notifications with action callbacks.
/// </summary>
public class NotificationService
{
private readonly string _appName;
private readonly string? _defaultIconPath;
private readonly ConcurrentDictionary<uint, NotificationContext> _activeNotifications = new();
private static uint _notificationIdCounter = 1;
private Process? _dBusMonitor;
private bool _monitoringActions;
/// <summary>
/// Event raised when a notification action is invoked.
/// </summary>
public event EventHandler<NotificationActionEventArgs>? ActionInvoked;
/// <summary>
/// Event raised when a notification is closed.
/// </summary>
public event EventHandler<NotificationClosedEventArgs>? NotificationClosed;
public NotificationService(string appName = "MAUI Application", string? defaultIconPath = null)
{
_appName = appName;
_defaultIconPath = defaultIconPath;
}
/// <summary>
/// Starts monitoring for notification action callbacks via D-Bus.
/// Call this once at application startup if you want to receive action callbacks.
/// </summary>
public void StartActionMonitoring()
{
if (_monitoringActions) return;
_monitoringActions = true;
// Start D-Bus monitor for notification signals
Task.Run(MonitorNotificationSignals);
}
/// <summary>
/// Stops monitoring for notification action callbacks.
/// </summary>
public void StopActionMonitoring()
{
_monitoringActions = false;
try
{
_dBusMonitor?.Kill();
_dBusMonitor?.Dispose();
_dBusMonitor = null;
}
catch (Exception ex) { DiagnosticLog.Debug("NotificationService", "D-Bus monitor cleanup failed", ex); }
}
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)
{
DiagnosticLog.Error("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 (Exception ex) { DiagnosticLog.Debug("NotificationService", "Action invoked processing failed", ex); }
}
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 (Exception ex) { DiagnosticLog.Debug("NotificationService", "Notification closed processing failed", ex); }
}
/// <summary>
/// Shows a simple notification.
/// </summary>
public async Task ShowAsync(string title, string message)
{
await ShowAsync(new NotificationOptions
{
Title = title,
Message = message
});
}
/// <summary>
/// Shows a notification with action buttons and callbacks.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification message.</param>
/// <param name="actions">List of action buttons with callbacks.</param>
/// <param name="tag">Optional tag to identify the notification in events.</param>
/// <returns>The notification ID.</returns>
public async Task<uint> ShowWithActionsAsync(
string title,
string message,
IEnumerable<NotificationAction> 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;
}
/// <summary>
/// Cancels/closes an active notification.
/// </summary>
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 (Exception ex) { DiagnosticLog.Debug("NotificationService", "Notification cancel failed", ex); }
}
/// <summary>
/// Shows a notification with options.
/// </summary>
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<string>();
// 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
}
}
/// <summary>
/// Checks if notifications are available on this system.
/// </summary>
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("\"", "\\\"") ?? "";
}
}