diff --git a/src/Avalonia.Controls/NativeMenuItem.cs b/src/Avalonia.Controls/NativeMenuItem.cs index 2eaf24d2f28..2ceaeb6dbaa 100644 --- a/src/Avalonia.Controls/NativeMenuItem.cs +++ b/src/Avalonia.Controls/NativeMenuItem.cs @@ -16,6 +16,7 @@ public class NativeMenuItem : NativeMenuItemBase, INativeMenuItemExporterEventsI private bool _isChecked = false; private NativeMenuItemToggleType _toggleType; private IBitmap _icon; + private readonly CanExecuteChangedSubscriber _canExecuteChangedSubscriber; private NativeMenu _menu; @@ -47,8 +48,6 @@ public void OnEvent(object sender, EventArgs e) } } - private readonly CanExecuteChangedSubscriber _canExecuteChangedSubscriber; - public NativeMenuItem() { diff --git a/src/Avalonia.Controls/TrayIcon.cs b/src/Avalonia.Controls/TrayIcon.cs index 6bfddfa877a..59edb6278a3 100644 --- a/src/Avalonia.Controls/TrayIcon.cs +++ b/src/Avalonia.Controls/TrayIcon.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Windows.Input; using Avalonia.Collections; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Platform; using Avalonia.Platform; +using Avalonia.Utilities; #nullable enable @@ -13,10 +15,13 @@ namespace Avalonia.Controls public sealed class TrayIcons : AvaloniaList { } + + public class TrayIcon : AvaloniaObject, INativeMenuExporterProvider, IDisposable { private readonly ITrayIconImpl? _impl; + private ICommand? _command; private TrayIcon(ITrayIconImpl? impl) { @@ -26,7 +31,15 @@ private TrayIcon(ITrayIconImpl? impl) _impl.SetIsVisible(IsVisible); - _impl.OnClicked = () => Clicked?.Invoke(this, EventArgs.Empty); + _impl.OnClicked = () => + { + Clicked?.Invoke(this, EventArgs.Empty); + + if (Command?.CanExecute(CommandParameter) == true) + { + Command.Execute(CommandParameter); + } + }; } } @@ -64,6 +77,21 @@ static TrayIcon() /// on OSX this event is not raised. /// public event EventHandler? Clicked; + + /// + /// Defines the property. + /// + public static readonly DirectProperty CommandProperty = + Button.CommandProperty.AddOwner( + trayIcon => trayIcon.Command, + (trayIcon, command) => trayIcon.Command = command, + enableDataValidation: true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty CommandParameterProperty = + Button.CommandParameterProperty.AddOwner(); /// /// Defines the attached property. @@ -98,6 +126,25 @@ public static readonly StyledProperty MenuProperty public static void SetIcons(AvaloniaObject o, TrayIcons trayIcons) => o.SetValue(IconsProperty, trayIcons); public static TrayIcons GetIcons(AvaloniaObject o) => o.GetValue(IconsProperty); + + /// + /// Gets or sets the property of a TrayIcon. + /// + public ICommand? Command + { + get => _command; + set => SetAndRaise(CommandProperty, ref _command, value); + } + + /// + /// Gets or sets the parameter to pass to the property of a + /// . + /// + public object CommandParameter + { + get { return GetValue(CommandParameterProperty); } + set { SetValue(CommandParameterProperty, value); } + } /// /// Gets or sets the Menu of the TrayIcon. diff --git a/src/Avalonia.FreeDesktop/DBusHelper.cs b/src/Avalonia.FreeDesktop/DBusHelper.cs index 4e23711ed4a..c14539d7bf4 100644 --- a/src/Avalonia.FreeDesktop/DBusHelper.cs +++ b/src/Avalonia.FreeDesktop/DBusHelper.cs @@ -12,7 +12,7 @@ public class DBusHelper /// This class uses synchronous execution at DBus connection establishment stage /// then switches to using AvaloniaSynchronizationContext /// - class DBusSyncContext : SynchronizationContext + private class DBusSyncContext : SynchronizationContext { private SynchronizationContext _ctx; private object _lock = new object(); @@ -51,10 +51,10 @@ public void Initialized() public static Connection TryInitialize(string dbusAddress = null) { - return Connection ?? TryGetConnection(dbusAddress); + return Connection ?? TryCreateNewConnection(dbusAddress); } - public static Connection TryGetConnection(string dbusAddress = null) + public static Connection TryCreateNewConnection(string dbusAddress = null) { var oldContext = SynchronizationContext.Current; try diff --git a/src/Avalonia.X11/X11TrayIconImpl.cs b/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs similarity index 68% rename from src/Avalonia.X11/X11TrayIconImpl.cs rename to src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs index 371ff754085..a7cc4f4cc26 100644 --- a/src/Avalonia.X11/X11TrayIconImpl.cs +++ b/src/Avalonia.FreeDesktop/DBusTrayIconImpl.cs @@ -6,55 +6,65 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using Avalonia.Controls.Platform; -using Avalonia.FreeDesktop; using Avalonia.Logging; using Avalonia.Platform; using Tmds.DBus; [assembly: InternalsVisibleTo(Connection.DynamicAssemblyName)] -namespace Avalonia.X11 +[assembly: + InternalsVisibleTo( + "Avalonia.X11, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c1bba1142285fe0419326fb25866ba62c47e6c2b5c1ab0c95b46413fad375471232cb81706932e1cef38781b9ebd39d5100401bacb651c6c5bbf59e571e81b3bc08d2a622004e08b1a6ece82a7e0b9857525c86d2b95fab4bc3dce148558d7f3ae61aa3a234086902aeface87d9dfdd32b9d2fe3c6dd4055b5ab4b104998bd87")] + +namespace Avalonia.FreeDesktop { - internal class X11TrayIconImpl : ITrayIconImpl + internal class DBusTrayIconImpl : ITrayIconImpl { private static int s_trayIconInstanceId; + private readonly ObjectPath _dbusMenuPath; - private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; private readonly Connection? _connection; - private DbusPixmap _icon; + private IDisposable? _serviceWatchDisposable; + private StatusNotifierItemDbusObj? _statusNotifierItemDbusObj; private IStatusNotifierWatcher? _statusNotifierWatcher; + private DbusPixmap _icon; private string? _sysTrayServiceName; private string? _tooltipText; - private bool _isActive; private bool _isDisposed; - private readonly bool _ctorFinished; + private bool _serviceConnected; + private bool _isVisible = true; + public bool IsActive { get; private set; } public INativeMenuExporter? MenuExporter { get; } public Action? OnClicked { get; set; } + public Func? IconConverterDelegate { get; set; } - public X11TrayIconImpl() + public DBusTrayIconImpl() { - _connection = DBusHelper.TryGetConnection(); + _connection = DBusHelper.TryCreateNewConnection(); if (_connection is null) { - Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + Logger.TryGet(LogEventLevel.Error, "DBUS") ?.Log(this, "Unable to get a dbus connection for system tray icons."); + return; } + IsActive = true; + _dbusMenuPath = DBusMenuExporter.GenerateDBusMenuObjPath; + MenuExporter = DBusMenuExporter.TryCreateDetachedNativeMenu(_dbusMenuPath, _connection); - CreateTrayIcon(); - _ctorFinished = true; + + WatchAsync(); } - public async void CreateTrayIcon() + private void InitializeSNWService() { - if (_connection is null) - return; + if (_connection is null || _isDisposed) return; try { @@ -64,12 +74,58 @@ public async void CreateTrayIcon() } catch { - Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + Logger.TryGet(LogEventLevel.Error, "DBUS") + ?.Log(this, + "org.kde.StatusNotifierWatcher service is not available on this system. Tray Icons will not work without it."); + + return; + } + + _serviceConnected = true; + } + + private async void WatchAsync() + { + try + { + _serviceWatchDisposable = + await _connection?.ResolveServiceOwnerAsync("org.kde.StatusNotifierWatcher", OnNameChange)!; + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Error, "DBUS") ?.Log(this, - "DBUS: org.kde.StatusNotifierWatcher service is not available on this system. System Tray Icons will not work without it."); + $"Unable to hook watcher method on org.kde.StatusNotifierWatcher: {e}"); } + } - if (_statusNotifierWatcher is null) + private void OnNameChange(ServiceOwnerChangedEventArgs obj) + { + if (_isDisposed) + return; + + if (!_serviceConnected & obj.NewOwner != null) + { + _serviceConnected = true; + InitializeSNWService(); + + DestroyTrayIcon(); + + if (_isVisible) + { + CreateTrayIcon(); + } + } + else if (_serviceConnected & obj.NewOwner is null) + { + DestroyTrayIcon(); + _serviceConnected = false; + } + } + + private void CreateTrayIcon() + { + if (_connection is null || !_serviceConnected || _isDisposed) return; var pid = Process.GetCurrentProcess().Id; @@ -78,45 +134,61 @@ public async void CreateTrayIcon() _sysTrayServiceName = $"org.kde.StatusNotifierItem-{pid}-{tid}"; _statusNotifierItemDbusObj = new StatusNotifierItemDbusObj(_dbusMenuPath); - await _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); - - await _connection.RegisterServiceAsync(_sysTrayServiceName); + try + { + _connection.RegisterObjectAsync(_statusNotifierItemDbusObj); + _connection.RegisterServiceAsync(_sysTrayServiceName); + _statusNotifierWatcher?.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + } + catch (Exception e) + { + Logger.TryGet(LogEventLevel.Error, "DBUS") + ?.Log(this, $"Error creating a DBus tray icon: {e}."); - await _statusNotifierWatcher.RegisterStatusNotifierItemAsync(_sysTrayServiceName); + _serviceConnected = false; + } _statusNotifierItemDbusObj.SetTitleAndTooltip(_tooltipText); _statusNotifierItemDbusObj.SetIcon(_icon); _statusNotifierItemDbusObj.ActivationDelegate += OnClicked; - - _isActive = true; } - public async void DestroyTrayIcon() + private void DestroyTrayIcon() { - if (_connection is null) + if (_connection is null || !_serviceConnected || _isDisposed || _statusNotifierItemDbusObj is null) return; + _connection.UnregisterObject(_statusNotifierItemDbusObj); - await _connection.UnregisterServiceAsync(_sysTrayServiceName); - _isActive = false; + _connection.UnregisterServiceAsync(_sysTrayServiceName); } public void Dispose() { + IsActive = false; _isDisposed = true; DestroyTrayIcon(); _connection?.Dispose(); + _serviceWatchDisposable?.Dispose(); } public void SetIcon(IWindowIconImpl? icon) { - if (_isDisposed) + if (_isDisposed || IconConverterDelegate is null) return; - if (!(icon is X11IconData x11icon)) + + if (icon is null) + { + _statusNotifierItemDbusObj?.SetIcon(DbusPixmap.EmptyPixmap); return; + } + + var x11iconData = IconConverterDelegate(icon); + + if (x11iconData.Length == 0) return; - var w = (int)x11icon.Data[0]; - var h = (int)x11icon.Data[1]; + var w = (int)x11iconData[0]; + var h = (int)x11iconData[1]; var pixLength = w * h; var pixByteArrayCounter = 0; @@ -124,7 +196,7 @@ public void SetIcon(IWindowIconImpl? icon) for (var i = 0; i < pixLength; i++) { - var rawPixel = x11icon.Data[i + 2].ToUInt32(); + var rawPixel = x11iconData[i + 2]; pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF000000) >> 24); pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF0000) >> 16); pixByteArray[pixByteArrayCounter++] = (byte)((rawPixel & 0xFF00) >> 8); @@ -137,18 +209,21 @@ public void SetIcon(IWindowIconImpl? icon) public void SetIsVisible(bool visible) { - if (_isDisposed || !_ctorFinished) + if (_isDisposed) return; - if (visible & !_isActive) - { - DestroyTrayIcon(); - CreateTrayIcon(); - } - else if (!visible & _isActive) + switch (visible) { - DestroyTrayIcon(); + case true when !_isVisible: + DestroyTrayIcon(); + CreateTrayIcon(); + break; + case false when _isVisible: + DestroyTrayIcon(); + break; } + + _isVisible = visible; } public void SetToolTipText(string? text) @@ -248,7 +323,20 @@ public Task WatchNewStatusAsync(Action handler, Action NewStatusAsync -= handler)); } - public Task GetAsync(string prop) => Task.FromResult(new object()); + public Task GetAsync(string prop) + { + return Task.FromResult(prop switch + { + nameof(_backingProperties.Category) => _backingProperties.Category, + nameof(_backingProperties.Id) => _backingProperties.Id, + nameof(_backingProperties.Menu) => _backingProperties.Menu, + nameof(_backingProperties.IconPixmap) => _backingProperties.IconPixmap, + nameof(_backingProperties.Status) => _backingProperties.Status, + nameof(_backingProperties.Title) => _backingProperties.Title, + nameof(_backingProperties.ToolTip) => _backingProperties.ToolTip, + _ => null + }); + } public Task GetAllAsync() => Task.FromResult(_backingProperties); @@ -298,17 +386,17 @@ internal interface IStatusNotifierItem : IDBusObject Task WatchNewOverlayIconAsync(Action handler, Action onError); Task WatchNewToolTipAsync(Action handler, Action onError); Task WatchNewStatusAsync(Action handler, Action onError); - Task GetAsync(string prop); + Task GetAsync(string prop); Task GetAllAsync(); Task SetAsync(string prop, object val); Task WatchPropertiesAsync(Action handler); } - [Dictionary] // This class is used by Tmds.Dbus to ferry properties // from the SNI spec. // Don't change this to actual C# properties since // Tmds.Dbus will get confused. + [Dictionary] internal class StatusNotifierItemProperties { public string? Category; @@ -363,5 +451,7 @@ public DbusPixmap(int width, int height, byte[] data) Height = height; Data = data; } + + public static DbusPixmap EmptyPixmap = new DbusPixmap(1, 1, new byte[] { 255, 0, 0, 0 }); } } diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index d3aeefd088f..8ff3b4f5e04 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using Avalonia.Controls; @@ -104,11 +105,25 @@ public void Initialize(X11PlatformOptions options) public IntPtr DeferredDisplay { get; set; } public IntPtr Display { get; set; } - public ITrayIconImpl CreateTrayIcon () + private static uint[] X11IconConverter(IWindowIconImpl icon) { - return new X11TrayIconImpl(); + if (!(icon is X11IconData x11icon)) + return Array.Empty(); + + return x11icon.Data.Select(x => x.ToUInt32()).ToArray(); } + public ITrayIconImpl CreateTrayIcon() + { + var dbusTrayIcon = new DBusTrayIconImpl(); + + if (!dbusTrayIcon.IsActive) return new XEmbedTrayIconImpl(); + + dbusTrayIcon.IconConverterDelegate = X11IconConverter; + + return dbusTrayIcon; + } + public IWindowImpl CreateWindow() { return new X11Window(this, null); diff --git a/src/Avalonia.X11/XEmbedTrayIconImpl.cs b/src/Avalonia.X11/XEmbedTrayIconImpl.cs new file mode 100644 index 00000000000..c046e49f934 --- /dev/null +++ b/src/Avalonia.X11/XEmbedTrayIconImpl.cs @@ -0,0 +1,47 @@ +using System; +using Avalonia.Controls.Platform; +using Avalonia.Logging; +using Avalonia.Platform; + +namespace Avalonia.X11 +{ + internal class XEmbedTrayIconImpl : ITrayIconImpl + { + + private bool _isCalled; + + private void NotImplemented() + { + if(_isCalled) return; + + Logger.TryGet(LogEventLevel.Error, LogArea.X11Platform) + ?.Log(this, + "TODO: XEmbed System Tray Icons is not implemented yet. Tray icons won't be available on this system."); + + _isCalled = true; + } + + public void Dispose() + { + NotImplemented(); + } + + public void SetIcon(IWindowIconImpl icon) + { + NotImplemented(); + } + + public void SetToolTipText(string text) + { + NotImplemented(); + } + + public void SetIsVisible(bool visible) + { + NotImplemented(); + } + + public INativeMenuExporter MenuExporter { get; } + public Action OnClicked { get; set; } + } +}