File: src\Aspire.Tray\Linux\LinuxSessionBus.cs
Web Access
Project: src\tests\Aspire.Tray.Tests\Aspire.Tray.Tests.csproj (Aspire.Tray.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
 
namespace Aspire.Tray;
 
/// <summary>
/// One-shot session bus queries made outside the GTK loop by the start helper and the detaching tray.
/// </summary>
[SupportedOSPlatform("linux")]
internal static partial class LinuxSessionBus
{
    private const int NoAutoStart = 1;
 
    /// <summary>
    /// Reports whether a panel host is registered with the desktop's StatusNotifierWatcher.
    /// </summary>
    internal static bool IsTrayHostRegistered()
    {
        try
        {
            // A missing watcher is an error reply. Do not activate one just to ask.
            // https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierWatcher/
            var registered = false;
            Call("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", "org.freedesktop.DBus.Properties", "Get",
                "('org.kde.StatusNotifierWatcher', 'IsStatusNotifierHostRegistered')", NoAutoStart, 1000, reply =>
                {
                    var boxed = g_variant_get_child_value(reply, 0);
                    try
                    {
                        var value = g_variant_get_variant(boxed);
                        try
                        {
                            registered = g_variant_is_of_type(value, "b") != 0 && g_variant_get_boolean(value) != 0;
                        }
                        finally
                        {
                            Gtk.g_variant_unref(value);
                        }
                    }
                    finally
                    {
                        Gtk.g_variant_unref(boxed);
                    }
                });
 
            return registered;
        }
        catch (InvalidOperationException)
        {
            return false;
        }
        catch (DllNotFoundException)
        {
            // The tray reported ready, so this should not happen. Without an answer, do not
            // claim a desktop problem that may not exist.
            return true;
        }
    }
 
    /// <summary>
    /// Moves this process into its own scope when it would otherwise die with a service unit.
    /// </summary>
    /// <remarks>
    /// systemd-xdg-autostart-generator runs the login entry's Exec as a Type=exec service. Before
    /// systemd v250 that unit has no ExitType=cgroup, so it stops when the launcher exits and the
    /// control group, including this detached tray, is killed. Desktop launchers place long-lived
    /// applications in their own scope for the same reason.
    /// https://github.com/systemd/systemd/commit/e83a422797
    /// https://systemd.io/DESKTOP_ENVIRONMENTS/
    /// </remarks>
    internal static void MoveOutOfServiceUnit()
    {
        const string CgroupPath = "/proc/self/cgroup";
        try
        {
            if (!IsInServiceUnit(File.ReadAllText(CgroupPath)))
            {
                return;
            }
            var unit = string.Create(CultureInfo.InvariantCulture, $"app-dev.aspire.tray-{Environment.ProcessId}.scope");
            // StartTransientUnit(name, mode, properties, auxiliary). CollectMode removes a
            // failed scope so that a reused PID cannot collide with its stale unit name.
            // https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html
            var parameters = string.Create(CultureInfo.InvariantCulture,
                $"('{unit}', 'fail', [('PIDs', <@au [uint32 {Environment.ProcessId}]>), ('CollectMode', <'inactive-or-failed'>), ('Slice', <'app.slice'>)], @a(sa(sv)) [])");
            Call("org.freedesktop.systemd1", "/org/freedesktop/systemd1", "org.freedesktop.systemd1.Manager",
                "StartTransientUnit", parameters, NoAutoStart, 2000, static _ => { });
 
            // The reply only means the job was queued. The launcher exits once the tray is
            // ready, so wait for the move rather than racing the old unit's shutdown.
            var deadline = Environment.TickCount64 + 2000;
            while (!File.ReadAllText(CgroupPath).Contains(unit, StringComparison.Ordinal))
            {
                if (Environment.TickCount64 >= deadline)
                {
                    Console.Error.WriteLine("Timed out moving the tray to its own systemd scope.");
                    return;
                }
                Thread.Sleep(20);
            }
        }
        catch (Exception ex) when (ex is InvalidOperationException or IOException or UnauthorizedAccessException or DllNotFoundException)
        {
            // Without a user manager on the session bus there is no unit to outlive, and a
            // failed move leaves the behavior newer systemd already handles. Keep starting.
            Console.Error.WriteLine($"Could not move the tray to its own systemd scope ({ex.GetType().Name}).");
        }
    }
 
    internal static bool IsInServiceUnit(string cgroup)
    {
        // /proc/self/cgroup has one hierarchy-id:controllers:path line per hierarchy:
        //   0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-aspire@autostart.service
        // A hybrid cgroup v1 host tracks units on name=systemd, and its "0::" line is often "/":
        //   12:cpu,cpuacct:/
        //   1:name=systemd:/user.slice/user-1000.slice/session-4.scope
        //   0::/
        // https://man7.org/linux/man-pages/man7/cgroups.7.html
        return cgroup.Split('\n', StringSplitOptions.RemoveEmptyEntries)
            .Select(line => line.Split(':', 3))
            .Any(fields => fields is [_, "" or "name=systemd", var path] && path.EndsWith(".service", StringComparison.Ordinal));
    }
 
    private static void Call(string name, string path, string interfaceName, string method, string parameters,
        int flags, int timeout, Action<nint> readReply)
    {
        var connection = g_bus_get_sync(2, 0, out var error); // G_BUS_TYPE_SESSION
        if (connection == 0)
        {
            FreeError(error);
            throw new InvalidOperationException("Could not connect to the session bus.");
        }
        try
        {
            // g_variant_parse avoids the variadic g_variant_new, and returns a non-floating
            // reference, so the call does not consume it and it is released below.
            // https://docs.gtk.org/glib/gvariant-text-format.html
            var arguments = g_variant_parse(0, parameters, 0, 0, out error);
            if (arguments == 0)
            {
                FreeError(error);
                throw new InvalidOperationException("Could not encode the session bus request.");
            }
            try
            {
                var reply = g_dbus_connection_call_sync(connection, name, path, interfaceName, method, arguments, 0, flags, timeout, 0, out error);
                if (reply == 0)
                {
                    FreeError(error);
                    throw new InvalidOperationException("The session bus request failed.");
                }
                try
                {
                    readReply(reply);
                }
                finally
                {
                    Gtk.g_variant_unref(reply);
                }
            }
            finally
            {
                Gtk.g_variant_unref(arguments);
            }
        }
        finally
        {
            Gtk.g_object_unref(connection);
        }
    }
 
    private static void FreeError(nint error)
    {
        if (error != 0)
        {
            LinuxDesktop.g_error_free(error);
        }
    }
 
    [LibraryImport("libgio-2.0.so.0")] private static partial nint g_bus_get_sync(int busType, nint cancellable, out nint error);
    [LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
    private static partial nint g_dbus_connection_call_sync(nint connection, string name, string path, string interfaceName,
        string method, nint parameters, nint replyType, int flags, int timeout, nint cancellable, out nint error);
    [LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
    private static partial nint g_variant_parse(nint type, string text, nint limit, nint endPointer, out nint error);
    [LibraryImport("libglib-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] private static partial int g_variant_is_of_type(nint value, string type);
    [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 nint g_variant_get_variant(nint value);
    [LibraryImport("libglib-2.0.so.0")] private static partial int g_variant_get_boolean(nint value);
}