// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Diagnostics;
namespace Aspire.Tray;
/// <summary>Exports the native tray icon independently of its application-owned panel.</summary>
internal sealed partial class LinuxStatusNotifierItem : IDisposable
{
internal const string ObjectPath = "/StatusNotifierItem";
internal const string InterfaceName = "org.kde.StatusNotifierItem";
// Unlike AppIndicator's menu-first frontend, ItemIsMenu=false exposes primary
// activation separately. The host supplies screen coordinates to our window.
// https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierItem/
internal const string InterfaceXml = """
<node>
<interface name="org.kde.StatusNotifierItem">
<method name="Activate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="SecondaryActivate"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="ContextMenu"><arg type="i" direction="in"/><arg type="i" direction="in"/></method>
<method name="Scroll"><arg type="i" direction="in"/><arg type="s" direction="in"/></method>
<property name="Category" type="s" access="read"/>
<property name="Id" type="s" access="read"/>
<property name="Title" type="s" access="read"/>
<property name="Status" type="s" access="read"/>
<property name="WindowId" type="u" access="read"/>
<property name="IconName" type="s" access="read"/>
<property name="IconPixmap" type="a(iiay)" access="read"/>
<property name="OverlayIconName" type="s" access="read"/>
<property name="OverlayIconPixmap" type="a(iiay)" access="read"/>
<property name="AttentionIconName" type="s" access="read"/>
<property name="AttentionIconPixmap" type="a(iiay)" access="read"/>
<property name="ToolTip" type="(sa(iiay)ss)" access="read"/>
<property name="ItemIsMenu" type="b" access="read"/>
<property name="Menu" type="o" access="read"/>
<signal name="NewIcon"/>
<signal name="NewToolTip"/>
</interface>
</node>
""";
private readonly nint _watcher;
private readonly Action<int, int> _activate;
private readonly nint _connection;
private readonly nint _node;
private readonly uint _registration;
private readonly nint _cancellable;
private int _pendingRegistrations;
private string _icon;
private string _description = "Aspire";
private string? _registeredOwner;
private string? _registeringOwner;
private Exception? _failure;
private bool _disposed;
private string? _retryOwner;
private long _retryAfter;
internal unsafe LinuxStatusNotifierItem(nint watcher, string icon, Action<int, int> activate)
{
_watcher = watcher;
_icon = icon;
_activate = activate;
try
{
_connection = g_bus_get_sync(2, 0, out var error);
if (_connection == 0)
{
FreeError(error);
throw new InvalidOperationException("Could not connect the tray icon to the session bus.");
}
_cancellable = g_cancellable_new();
_node = g_dbus_node_info_new_for_xml(InterfaceXml, out error);
if (_node == 0)
{
FreeError(error);
throw new InvalidOperationException("Could not create the tray icon's D-Bus interface.");
}
var handle = GCHandle.Alloc(this);
var table = new InterfaceVTable
{
Method = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, nint, nint, nint, nint, nint, void>)&Method,
Property = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, nint, nint, nint, nint, nint>)&Property
};
// GIO copies the vtable and owns the callback handle until unregistration.
// https://docs.gtk.org/gio/method.DBusConnection.register_object.html
_registration = g_dbus_connection_register_object(_connection, ObjectPath,
g_dbus_node_info_lookup_interface(_node, InterfaceName), &table, GCHandle.ToIntPtr(handle),
(nint)(delegate* unmanaged[Cdecl]<nint, void>)&Destroy, out error);
if (_registration == 0)
{
handle.Free();
FreeError(error);
throw new InvalidOperationException("Could not export the native tray icon.");
}
}
catch
{
Dispose();
throw;
}
}
internal bool IsRegistered => _registeredOwner is not null;
internal unsafe void PollRegistration()
{
if (_failure is not null)
{
throw new InvalidOperationException("The native tray icon failed.", _failure);
}
var owner = GetWatcherOwner();
if (owner == _registeredOwner)
{
return;
}
_registeredOwner = null;
if (owner is null || owner == _registeringOwner
|| (owner == _retryOwner && Environment.TickCount64 < _retryAfter))
{
return;
}
_registeringOwner = owner;
var request = GCHandle.Alloc(new Registration(this, owner));
_pendingRegistrations++;
var parameters = g_variant_ref_sink(Tuple([g_variant_new_string(ObjectPath)]));
try
{
// Registration must be asynchronous: a watcher can inspect our exported
// properties before replying, and their callbacks run on this GTK context.
g_dbus_proxy_call(_watcher, "RegisterStatusNotifierItem", parameters, 0, 5000, _cancellable,
(nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&Registered, GCHandle.ToIntPtr(request));
}
catch
{
request.Free();
_pendingRegistrations--;
throw;
}
finally
{
Gtk.g_variant_unref(parameters);
}
}
private string? GetWatcherOwner()
{
var value = g_dbus_proxy_get_name_owner(_watcher);
try
{
return Marshal.PtrToStringUTF8(value);
}
finally
{
g_free(value);
}
}
internal void Update(string icon, string description)
{
if (_icon != icon)
{
_icon = icon;
Emit("NewIcon");
}
if (_description != description)
{
_description = description;
Emit("NewToolTip");
}
}
private void Emit(string signal)
{
if (g_dbus_connection_emit_signal(_connection, 0, ObjectPath, InterfaceName, signal, 0, out var error) == 0)
{
FreeError(error);
throw new InvalidOperationException("Could not update the native tray icon.");
}
}
private nint GetProperty(string name) => name switch
{
"Category" => g_variant_new_string("ApplicationStatus"),
"Id" => g_variant_new_string("dev.aspire.tray"),
"Title" => g_variant_new_string("Aspire"),
"Status" => g_variant_new_string("Active"),
"WindowId" => g_variant_new_uint32(0),
"IconName" => g_variant_new_string(_icon),
"OverlayIconName" or "AttentionIconName" => g_variant_new_string(""),
"IconPixmap" or "OverlayIconPixmap" or "AttentionIconPixmap" => EmptyPixmaps(),
"ToolTip" => Tuple([g_variant_new_string(_icon), EmptyPixmaps(),
g_variant_new_string("Aspire"), g_variant_new_string(_description)]),
"ItemIsMenu" => g_variant_new_boolean(0),
"Menu" => g_variant_new_object_path("/NO_DBUSMENU"),
_ => throw new InvalidOperationException("Unknown tray icon property.")
};
private static unsafe nint EmptyPixmaps() => g_variant_new_array("(iiay)", null, 0);
private static unsafe nint Tuple(ReadOnlySpan<nint> children)
{
fixed (nint* pointer = children)
{
return g_variant_new_tuple(pointer, (nuint)children.Length);
}
}
private static int Coordinate(nint parameters, nuint index)
{
var child = g_variant_get_child_value(parameters, index);
try
{
return g_variant_get_int32(child);
}
finally
{
Gtk.g_variant_unref(child);
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void Method(nint connection, nint sender, nint path, nint iface, nint method, nint parameters, nint invocation, nint data)
{
var item = (LinuxStatusNotifierItem)GCHandle.FromIntPtr(data).Target!;
try
{
var name = Marshal.PtrToStringUTF8(method);
switch (name)
{
case "Activate" or "SecondaryActivate" or "ContextMenu":
item._activate(Coordinate(parameters, 0), Coordinate(parameters, 1));
break;
case "Scroll":
break;
default:
throw new InvalidOperationException("Unknown tray icon method.");
}
g_dbus_method_invocation_return_value(invocation, 0);
}
catch (Exception ex)
{
item._failure = ex;
g_dbus_method_invocation_return_dbus_error(invocation, "org.freedesktop.DBus.Error.Failed",
"The tray could not handle the desktop activation request.");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint Property(nint connection, nint sender, nint path, nint iface, nint property, nint error, nint data)
{
var item = (LinuxStatusNotifierItem)GCHandle.FromIntPtr(data).Target!;
try
{
return item.GetProperty(Marshal.PtrToStringUTF8(property)!);
}
catch (Exception ex)
{
item._failure = ex;
g_set_error_literal(error, g_dbus_error_quark(), 0, "The tray icon property could not be read.");
return 0;
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void Registered(nint proxy, nint result, nint data)
{
var handle = GCHandle.FromIntPtr(data);
var request = (Registration)handle.Target!;
var item = request.Item;
try
{
var reply = g_dbus_proxy_call_finish(proxy, result, out var error);
if (reply != 0)
{
Gtk.g_variant_unref(reply);
}
FreeError(error);
if (item._registeringOwner == request.Owner)
{
item._registeringOwner = null;
}
if (!item._disposed && item.GetWatcherOwner() == request.Owner)
{
if (reply == 0)
{
// A slow/restarting watcher can reject or time out one request.
// Keep the item exported and retry independently of when
// the desktop panel finishes starting. Retries are unbounded and the
// diagnostic log has no size limit, so log once per watcher owner.
if (item._retryOwner != request.Owner)
{
Console.Error.WriteLine("The desktop tray watcher could not register Aspire. Retrying.");
}
item._retryOwner = request.Owner;
item._retryAfter = Environment.TickCount64 + 1000;
}
else
{
item._retryOwner = null;
item._registeredOwner = request.Owner;
}
}
}
catch (Exception ex)
{
if (!item._disposed)
{
item._failure = ex;
}
}
finally
{
item._pendingRegistrations--;
handle.Free();
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void Destroy(nint data) => GCHandle.FromIntPtr(data).Free();
private static void FreeError(nint error)
{
if (error != 0)
{
LinuxDesktop.g_error_free(error);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_cancellable != 0)
{
g_cancellable_cancel(_cancellable);
// Complete canceled callbacks before releasing their UI-context handles.
// Drain only during shutdown, with a bound if the bus has already failed.
var started = Stopwatch.GetTimestamp();
while (_pendingRegistrations > 0 && Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(1))
{
if (g_main_context_iteration(0, 0) == 0)
{
Thread.Sleep(1);
}
}
if (_pendingRegistrations > 0)
{
Console.Error.WriteLine("Native tray registration cancellation did not finish before shutdown.");
}
Gtk.g_object_unref(_cancellable);
}
if (_registration != 0)
{
g_dbus_connection_unregister_object(_connection, _registration);
}
if (_node != 0)
{
g_dbus_node_info_unref(_node);
}
if (_connection != 0)
{
Gtk.g_object_unref(_connection);
}
}
[StructLayout(LayoutKind.Sequential)]
private struct InterfaceVTable
{
public nint Method, Property, SetProperty;
public nint Padding0, Padding1, Padding2, Padding3, Padding4, Padding5, Padding6, Padding7;
}
[LibraryImport("libgio-2.0.so.0")] private static partial nint g_bus_get_sync(int type, nint cancellable, out nint error);
[LibraryImport("libgio-2.0.so.0")] private static partial nint g_cancellable_new();
[LibraryImport("libgio-2.0.so.0")] private static partial void g_cancellable_cancel(nint cancellable);
[LibraryImport("libgio-2.0.so.0")] private static partial uint g_dbus_error_quark();
[LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static partial void g_set_error_literal(nint location, uint domain, int code, string message);
[LibraryImport("libglib-2.0.so.0")] private static partial int g_main_context_iteration(nint context, int block);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial nint g_dbus_node_info_new_for_xml(string xml, out nint error);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial nint g_dbus_node_info_lookup_interface(nint node, string name);
[LibraryImport("libgio-2.0.so.0")] private static partial void g_dbus_node_info_unref(nint node);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static unsafe partial uint g_dbus_connection_register_object(nint connection, string path, nint info,
InterfaceVTable* table, nint data, nint destroy, out nint error);
[LibraryImport("libgio-2.0.so.0")] private static partial int g_dbus_connection_unregister_object(nint connection, uint registration);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial void g_dbus_proxy_call(nint proxy, string method, nint parameters, int flags, int timeout,
nint cancellable, nint callback, nint data);
[LibraryImport("libgio-2.0.so.0")] private static partial nint g_dbus_proxy_call_finish(nint proxy, nint result, out nint error);
[LibraryImport("libgio-2.0.so.0")] private static partial nint g_dbus_proxy_get_name_owner(nint proxy);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial int g_dbus_connection_emit_signal(nint connection, nint destination, string path,
string iface, string signal, nint parameters, out nint error);
[LibraryImport("libgio-2.0.so.0")] private static partial void g_dbus_method_invocation_return_value(nint invocation, nint value);
[LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
private static partial void g_dbus_method_invocation_return_dbus_error(nint invocation, string name, string message);
[LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static partial nint g_variant_new_string(string value);
[LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static partial nint g_variant_new_object_path(string value);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_new_uint32(uint value);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_new_boolean(int value);
[LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static unsafe partial nint g_variant_new_array(string type, nint* children, nuint count);
[LibraryImport("libglib-2.0.so.0")] private static unsafe partial nint g_variant_new_tuple(nint* children, nuint count);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_ref_sink(nint value);
[LibraryImport("libglib-2.0.so.0")] private static partial nint g_variant_get_child_value(nint value, nuint index);
[LibraryImport("libglib-2.0.so.0")] private static partial int g_variant_get_int32(nint value);
[LibraryImport("libglib-2.0.so.0")] private static partial void g_free(nint value);
private sealed record Registration(LinuxStatusNotifierItem Item, string Owner);
}