File: src\Aspire.Tray\Linux\LinuxTrayApplication.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.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
 
namespace Aspire.Tray;
 
/// <summary>
/// Owns GTK on the initial thread; AppIndicator exports its menus over the session bus.
/// </summary>
internal sealed class LinuxTrayApplication(TrayController controller, ITrayStartupSettings startup, TimeProvider timeProvider) : IDisposable
{
    private static LinuxTrayApplication? s_current;
    private readonly ConcurrentQueue<Action> _pending = new();
    private readonly Dictionary<nint, LinuxTrayMenuItem> _commands = [];
    private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private readonly List<Task> _desktopTasks = [];
    private TrayViewState? _displayed;
    private nint _indicator;
    private nint _watcher;
    private nint _menu;
    private uint _timer;
    private uint _idle;
    private nint _dialog;
    private int _quitRequested;
    private bool _quitting;
    private bool _connected;
    private bool _waitingForHost;
    private Exception? _failure;
    private long? _lastPinPrune;
    private IReadOnlyList<LinuxFolderApplication> _applications = [];
    private LinuxTrayIcons? _icons;
    private TrayIconStatus? _iconStatus;
    private LinuxTrayPanel? _panel;
    private LinuxStatusNotifierItem? _panelIcon;
 
    internal LinuxTrayApplication(TrayController controller, ITrayStartupSettings startup)
        : this(controller, startup, TimeProvider.System)
    {
    }
 
    private bool IsQuitting => _quitting || Volatile.Read(ref _quitRequested) != 0;
 
    internal bool IsAvailable { get; private set; }
 
    internal unsafe void Run()
    {
        Gtk.Load();
        var nativeMenu = LinuxDesktopEnvironment.IsGnome(Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP"),
            Environment.GetEnvironmentVariable("XDG_SESSION_DESKTOP"));
        if (!nativeMenu)
        {
            LinuxLayerShell.Preload();
        }
        if (Gtk.gtk_init_check(0, 0) == 0)
        {
            throw new InvalidOperationException("GTK could not connect to a graphical desktop. Run Aspire Tray inside your Wayland or X11 session.");
        }
        s_current = this;
        // The proxy follows PropertiesChanged and watcher owner changes on GTK's
        // main context. Do not start a watcher; reload invalidated properties.
        // https://docs.gtk.org/gio/class.DBusProxy.html
        _watcher = Gtk.g_dbus_proxy_new_for_bus_sync(2, 4 | 8, 0,
            "org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher", "org.kde.StatusNotifierWatcher", 0, out var error);
        if (_watcher == 0)
        {
            if (error != 0)
            {
                LinuxDesktop.g_error_free(error);
            }
            throw new InvalidOperationException("Could not observe the desktop's StatusNotifierWatcher on the session bus.");
        }
        var icon = Path.Combine(AppContext.BaseDirectory, "Aspire.png");
        if (!File.Exists(icon))
        {
            throw new FileNotFoundException("The tray icon is missing.", icon);
        }
        _icons = new LinuxTrayIcons(icon);
        _applications = LinuxFolderApplication.Discover(Environment.GetEnvironmentVariable("PATH") ?? "");
        if (!nativeMenu)
        {
            _panel = new(controller, _applications, ExecutePanelAction, LinuxDesktopEnvironment.GetPanelBackend());
            _panelIcon = new(_watcher, icon, (x, y) => _pending.Enqueue(() => ActivatePanel(x, y)));
        }
        else
        {
            _indicator = Gtk.app_indicator_new("dev.aspire.tray", icon, 0);
            if (_indicator == 0)
            {
                throw new InvalidOperationException("Could not create the Linux AppIndicator.");
            }
            Gtk.app_indicator_set_title(_indicator, "Aspire");
            Gtk.g_signal_connect_data(_indicator, "connection-changed",
                (nint)(delegate* unmanaged[Cdecl]<nint, int, nint, void>)&ConnectionChanged, 0, 0, 0);
        }
        Refresh();
        if (_indicator != 0)
        {
            Gtk.app_indicator_set_status(_indicator, 1);
        }
        _timer = Gtk.g_timeout_add(100, (nint)(delegate* unmanaged[Cdecl]<nint, int>)&Tick, 0);
        Gtk.gtk_main();
        if (_failure is not null)
        {
            throw new InvalidOperationException($"The Linux tray UI failed: {_failure.Message}", _failure);
        }
    }
 
    private void ActivatePanel(int x, int y)
    {
        try
        {
            LinuxDesktopEnvironment.RequirePanelBackend();
        }
        catch (InvalidOperationException ex)
        {
            // The panel is the only UI on these desktops, and its failure notice is drawn
            // inside it. A plain GTK dialog does not need a compositor, so it can explain
            // why the panel cannot open. This runs from the pending-action source, so Tick
            // can still cancel the dialog on stop/SIGTERM.
            Console.Error.WriteLine($"Linux flyout unavailable ({ex.GetType().Name}).");
            ShowMessage("Aspire Tray", ex.Message);
            return;
        }
        _panel!.Activate(x, y);
    }
 
    internal Task ReadyAsync(CancellationToken token) => _ready.Task.WaitAsync(token);
 
    // The launcher needs an acknowledged UI loop and bundle lease, not a desktop
    // host that may start later during login. Stop must also work while waiting.
    internal void CompleteInitialization() => _ready.TrySetResult();
 
    internal async Task RestoreAsync(CancellationToken token)
    {
        await ReadyAsync(token).ConfigureAwait(false);
        var restored = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        _pending.Enqueue(() =>
        {
            try
            {
                if (token.IsCancellationRequested)
                {
                    restored.TrySetCanceled(token);
                }
                else if (IsQuitting)
                {
                    restored.TrySetException(new InvalidOperationException("The tray is shutting down."));
                }
                else
                {
                    // Repeated start restores the status item, not an input-grabbing
                    // popup. Only an explicit desktop activation should open the panel.
                    _panelIcon?.PollRegistration();
                    if (_indicator != 0)
                    {
                        Gtk.app_indicator_set_status(_indicator, 0);
                        Gtk.app_indicator_set_status(_indicator, 1);
                    }
                    restored.TrySetResult();
                }
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"Tray icon restoration failed ({ex.GetType().Name}).");
                restored.TrySetException(ex);
            }
        });
        await restored.Task.WaitAsync(token).ConfigureAwait(false);
    }
 
    internal void RequestQuit() => Volatile.Write(ref _quitRequested, 1);
 
    internal void ProcessPending()
    {
        while (_pending.TryDequeue(out var action))
        {
            action();
        }
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static void ConnectionChanged(nint indicator, int connected, nint data)
    {
        // This confirms item registration, not that a panel host is available.
        s_current!._connected = connected != 0;
    }
 
    private bool HasRegisteredHost()
    {
        var value = Gtk.g_dbus_proxy_get_cached_property(_watcher, "IsStatusNotifierHostRegistered");
        if (value == 0)
        {
            return false;
        }
        try
        {
            if (Gtk.g_variant_is_of_type(value, "b") == 0)
            {
                throw new InvalidOperationException("The StatusNotifierWatcher's host registration property is not a boolean.");
            }
            return Gtk.g_variant_get_boolean(value) != 0;
        }
        finally
        {
            Gtk.g_variant_unref(value);
        }
    }
 
    internal void UpdateAvailability(bool watcherConnected, bool hostRegistered)
    {
        // Watcher registration alone can succeed without any panel displaying items.
        // https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/StatusNotifierWatcher/
        var available = watcherConnected && hostRegistered;
        if (!available && !_waitingForHost)
        {
            Console.Error.WriteLine("Waiting for desktop tray support. Enable an AppIndicator extension on GNOME or your desktop's tray module. The icon will appear when a StatusNotifierWatcher and panel host are available.");
        }
        else if (available && _waitingForHost)
        {
            Console.Error.WriteLine("Desktop tray support is available. The tray icon is registered.");
        }
        _waitingForHost = !available;
        IsAvailable = available;
    }
 
    private void Quit()
    {
        _quitting = true;
        _panel?.Hide();
        if (_dialog != 0)
        {
            // gtk_dialog_run owns a separate nested loop. Cancel it before quitting
            // gtk_main so confirmations cannot commit actions during shutdown.
            // https://docs.gtk.org/gtk3/method.Dialog.run.html
            Gtk.gtk_dialog_response(_dialog, -6);
        }
        else
        {
            Gtk.gtk_main_quit();
        }
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static unsafe int Tick(nint data)
    {
        var app = s_current!;
        try
        {
            if (app.IsQuitting)
            {
                app.Quit();
                return 1;
            }
            // Retain command identities during modal dialogs, but never defer shutdown.
            if (app._dialog != 0)
            {
                return 1;
            }
            app._panelIcon?.PollRegistration();
            app._panel?.Poll();
            app.UpdateAvailability(app._panelIcon?.IsRegistered ?? app._connected, app.HasRegisteredHost());
            if (!app.IsAvailable)
            {
                app._panel?.Hide();
            }
            if (app._idle == 0 && !app._pending.IsEmpty)
            {
                // A queued action can run gtk_dialog_run's nested main loop.
                // Dispatch it from a separate source so Tick remains available
                // to cancel dialogs on stop/SIGTERM (sources do not reenter).
                // https://docs.gtk.org/glib/method.Source.set_can_recurse.html
                app._idle = Gtk.g_idle_add((nint)(delegate* unmanaged[Cdecl]<nint, int>)&DispatchPending, 0);
            }
            if (!app.IsQuitting)
            {
                app.Refresh();
                app.CompleteInitialization();
            }
        }
        catch (Exception ex)
        {
            app._failure = ex;
            app._ready.TrySetException(ex);
            app.Quit();
        }
        return 1;
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static int DispatchPending(nint data)
    {
        var app = s_current!;
        app._idle = 0;
        try
        {
            app.ProcessPending();
        }
        catch (Exception ex)
        {
            app._failure = ex;
            app._ready.TrySetException(ex);
            app.Quit();
        }
 
        return 0;
    }
 
    private void Refresh()
    {
        var state = GetStateForRefresh();
        if (state is null)
        {
            return;
        }
        var previous = _menu;
        _panel?.Update(state);
        if (_panelIcon is null)
        {
            _commands.Clear();
            _menu = BuildMenu(LinuxTrayMenu.Build(state, _applications));
            // Retain our own reference: replacing the indicator's menu drops its reference
            // before we disconnect/destroy the old widgets and their callbacks.
            Gtk.g_object_ref_sink(_menu);
            Gtk.gtk_widget_show_all(_menu);
            Gtk.app_indicator_set_menu(_indicator, _menu);
            Gtk.app_indicator_set_title(_indicator, $"Aspire - {state.Status}");
        }
        var status = TrayIconStatusRules.Get(state);
        if (_iconStatus != status)
        {
            var icon = _icons!.GetPath(status);
            var description = $"Aspire - {TrayIconStatusRules.Describe(status)}";
            if (_panelIcon is not null)
            {
                _panelIcon.Update(icon, description);
            }
            else
            {
                Gtk.app_indicator_set_icon_full(_indicator, icon, description);
            }
            _iconStatus = status;
        }
        if (previous != 0)
        {
            Gtk.gtk_widget_destroy(previous);
            Gtk.g_object_unref(previous);
        }
    }
 
    internal TrayViewState? GetStateForRefresh()
    {
        var now = timeProvider.GetTimestamp();
        // Discovery heartbeats do not publish new states. Probe pins independently,
        // but avoid filesystem access on every 100 ms GTK timer tick.
        if (_lastPinPrune is not { } previous || timeProvider.GetElapsedTime(previous, now) >= TimeSpan.FromSeconds(1))
        {
            controller.PruneMissingPinnedAppHosts();
            _lastPinPrune = now;
        }
        var state = controller.State;
        if (ReferenceEquals(_displayed, state))
        {
            return null;
        }
        _displayed = state;
        return state;
    }
 
    private unsafe nint BuildMenu(IReadOnlyList<LinuxTrayMenuItem> items)
    {
        var menu = Gtk.gtk_menu_new();
        foreach (var item in items)
        {
            var widget = item.Action == LinuxTrayAction.Separator ? Gtk.gtk_separator_menu_item_new()
                : item.Icon == LinuxTrayIcon.None ? Gtk.gtk_menu_item_new_with_label(item.Label)
                : Gtk.gtk_image_menu_item_new_with_label(item.Label);
            if (item.Icon != LinuxTrayIcon.None)
            {
                // libdbusmenu's GTK parser recognizes GtkImageMenuItem, not an arbitrary
                // image/label box. Keep using this GTK 3 widget for icon-name/icon-data export.
                // https://git.launchpad.net/libdbusmenu/tree/libdbusmenu-gtk/parser.c
                var image = item.Icon switch
                {
                    LinuxTrayIcon.Documentation => Gtk.gtk_image_new_from_icon_name("help-browser", 1),
                    LinuxTrayIcon.Settings => Gtk.gtk_image_new_from_icon_name("preferences-system", 1),
                    _ => CreateHealthImage(item.Icon)
                };
                Gtk.gtk_image_menu_item_set_image(widget, image);
                Gtk.gtk_image_menu_item_set_always_show_image(widget, 1);
            }
            Gtk.gtk_widget_set_sensitive(widget, item.Enabled ? 1 : 0);
            Gtk.gtk_menu_shell_append(menu, widget);
            if (item.Children is not null)
            {
                Gtk.gtk_menu_item_set_submenu(widget, BuildMenu(item.Children));
            }
            else if (item.Action is not (LinuxTrayAction.None or LinuxTrayAction.Separator))
            {
                _commands.Add(widget, item);
                Gtk.g_signal_connect_data(widget, "activate", (nint)(delegate* unmanaged[Cdecl]<nint, nint, void>)&Activate, 0, 0, 0);
            }
        }
        return menu;
    }
 
    private static nint CreateHealthImage(LinuxTrayIcon icon)
    {
        // Cairo is already a GTK dependency. Export a small antialiased PNG through
        // GdkPixbuf so health colors do not depend on the panel's installed icon theme.
        const int size = 16;
        var surface = Gtk.cairo_image_surface_create(0, size, size); // CAIRO_FORMAT_ARGB32
        var context = Gtk.cairo_create(surface);
        try
        {
            var color = icon switch
            {
                LinuxTrayIcon.Healthy => 0x269653,
                LinuxTrayIcon.Warning => 0xE58A00,
                LinuxTrayIcon.Unhealthy => 0xD63E42,
                _ => 0x929292
            };
            Draw(size * 0.3, 0x606060);
            Draw(size * 0.3 - 1, color);
            if (Gtk.cairo_surface_status(surface) != 0 || Gtk.cairo_status(context) != 0)
            {
                throw new InvalidOperationException("Could not render the AppHost health icon.");
            }
            var pixels = Gtk.gdk_pixbuf_get_from_surface(surface, 0, 0, size, size);
            if (pixels == 0)
            {
                throw new InvalidOperationException("Could not create the AppHost health image.");
            }
            try
            {
                return Gtk.gtk_image_new_from_pixbuf(pixels);
            }
            finally
            {
                Gtk.g_object_unref(pixels);
            }
        }
        finally
        {
            Gtk.cairo_destroy(context);
            Gtk.cairo_surface_destroy(surface);
        }
 
        void Draw(double radius, int color)
        {
            Gtk.cairo_set_source_rgb(context, ((color >> 16) & 255) / 255d, ((color >> 8) & 255) / 255d, (color & 255) / 255d);
            if (icon == LinuxTrayIcon.Stopped)
            {
                Gtk.cairo_rectangle(context, size / 2d - radius, size / 2d - radius, radius * 2, radius * 2);
            }
            else
            {
                Gtk.cairo_arc(context, size / 2d, size / 2d, radius, 0, Math.Tau);
            }
            Gtk.cairo_fill(context);
        }
    }
 
    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
    private static void Activate(nint widget, nint data)
    {
        var app = s_current!;
        try
        {
            if (!app.IsQuitting && app._dialog == 0
                && app._commands.TryGetValue(widget, out var item) && item.Enabled)
            {
                app.Execute(item);
            }
        }
        catch (Exception ex)
        {
            // Never include dashboard URLs or raw process output in diagnostics.
            Console.Error.WriteLine($"Tray action failed ({ex.GetType().Name}).");
            app.ShowActionFailure(ex);
        }
    }
 
    internal void Execute(LinuxTrayMenuItem item)
    {
        var host = item.Host;
        if (item.Action is LinuxTrayAction.Start or LinuxTrayAction.Folder or LinuxTrayAction.OpenIn
            && !ProjectFileExists(host!.Id.AppHostPath))
        {
            if (!host.IsRunning && !host.IsPinned)
            {
                if (Confirm("AppHost file not found", "Remove this missing project from recently opened?", "Remove"))
                {
                    controller.RemoveRecent(host.Id.AppHostPath);
                }
            }
            else
            {
                ShowMessage("AppHost file not found", "The AppHost project no longer exists at its saved location.");
            }
            return;
        }
        switch (item.Action)
        {
            case LinuxTrayAction.Dashboard:
                OpenUri(controller.GetDashboardUri(host!.Id).AbsoluteUri);
                break;
            case LinuxTrayAction.Stop:
                controller.RequestStop(host!.Id);
                break;
            case LinuxTrayAction.Start:
                controller.RequestStart(host!.Id.AppHostPath);
                break;
            case LinuxTrayAction.TogglePin:
                controller.SetPinned(host!.Id.AppHostPath, !host.IsPinned);
                break;
            case LinuxTrayAction.RemoveRecent:
                controller.RemoveRecent(host!.Id.AppHostPath);
                break;
            case LinuxTrayAction.Folder:
                // File-manager activation can wait for D-Bus service startup. Keep
                // GTK responsive so stop and SIGTERM still interrupt open dialogs.
                _desktopTasks.RemoveAll(task => task.IsCompleted);
                _desktopTasks.Add(RevealFileAsync(host!.Id.AppHostPath));
                break;
            case LinuxTrayAction.CopyPath:
                CopyPath(Path.GetDirectoryName(host!.Id.AppHostPath)!);
                break;
            case LinuxTrayAction.OpenIn:
                var application = item.Application ?? throw new InvalidOperationException("The selected application is no longer available.");
                application.Open(Path.GetDirectoryName(host!.Id.AppHostPath)!);
                break;
            case LinuxTrayAction.ClearRecent:
                if (Confirm("Clear recently opened?", "This cannot be undone. Pinned AppHosts will be kept.", "Clear"))
                {
                    controller.ClearRecent();
                }
                break;
            case LinuxTrayAction.Settings:
                ShowSettings();
                break;
            case LinuxTrayAction.Documentation:
                OpenUri("https://aspire.dev");
                break;
            case LinuxTrayAction.Quit:
                Quit();
                break;
        }
    }
 
    internal static bool ProjectFileExists(string path)
    {
        if (TrayAppHostPath.IsMissing(path))
        {
            return false;
        }
        TrayAppHostPath.RequireExistingFile(path);
 
        return true;
    }
 
    private async Task RevealFileAsync(string path)
    {
        try
        {
            await Task.Run(() => LinuxFileManager.ShowItem(path)).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"File-manager action failed ({ex.GetType().Name}).");
            _pending.Enqueue(() =>
            {
                if (!IsQuitting)
                {
                    ShowActionFailure(ex);
                }
            });
        }
    }
 
    private void ShowActionFailure(Exception error)
    {
        var message = error is InvalidOperationException or FileNotFoundException
            ? error.Message : "The requested action failed. Check your desktop application configuration.";
        controller.ReportActionError(message);
        if (_panel is not null)
        {
            if (!_panel.Visible)
            {
                _panel.Show();
            }
            _panel.Update(controller.State);
        }
        else
        {
            ShowMessage("Aspire action failed", message);
        }
    }
 
    private void ExecutePanelAction(PanelAction action)
    {
        try
        {
            ExecutePanelCommand(action);
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"Tray action failed ({ex.GetType().Name}).");
            ShowActionFailure(ex);
        }
    }
 
    private void ExecutePanelCommand(PanelAction action)
    {
        switch (action.Kind)
        {
            case PanelActionKind.Settings:
                _panel!.ShowSettings(PanelSettings.Read(startup, preview: false, LogPanelSettingsFailure));
                break;
            case PanelActionKind.SetLaunchAtSignIn:
                _panel!.UpdateSettings(PanelSettings.SetLaunchAtSignIn(startup, action.Enabled, preview: false, LogPanelSettingsFailure));
                break;
            case PanelActionKind.ClearRecent:
                controller.ClearRecent();
                break;
            case PanelActionKind.Close:
                _panel!.Hide();
                break;
            case PanelActionKind.ToggleRecent:
                break;
            default:
                if (action.Kind is PanelActionKind.Start or PanelActionKind.OpenIn or PanelActionKind.ShowInFolder
                    && !ProjectFileExists(action.Host!.Id.AppHostPath))
                {
                    if (action.Recent)
                    {
                        _panel!.ConfirmRemoveMissing(action.Host);
                    }
                    else
                    {
                        controller.ReportActionError("The AppHost project no longer exists at its saved location.");
                        _panel!.Update(controller.State);
                    }
                    break;
                }
                if (action.Kind is PanelActionKind.OpenDashboard or PanelActionKind.Documentation or PanelActionKind.OpenIn
                    or PanelActionKind.ShowInFolder or PanelActionKind.Quit)
                {
                    _panel!.Hide();
                }
                var command = action.Kind switch
                {
                    PanelActionKind.OpenDashboard => LinuxTrayAction.Dashboard,
                    PanelActionKind.Stop => LinuxTrayAction.Stop,
                    PanelActionKind.Start => LinuxTrayAction.Start,
                    PanelActionKind.OpenIn => LinuxTrayAction.OpenIn,
                    PanelActionKind.ShowInFolder => LinuxTrayAction.Folder,
                    PanelActionKind.CopyPath => LinuxTrayAction.CopyPath,
                    PanelActionKind.TogglePin => LinuxTrayAction.TogglePin,
                    PanelActionKind.RemoveRecent => LinuxTrayAction.RemoveRecent,
                    PanelActionKind.Documentation => LinuxTrayAction.Documentation,
                    PanelActionKind.Quit => LinuxTrayAction.Quit,
                    _ => throw new InvalidOperationException("The selected flyout action is not supported.")
                };
                Execute(new("", command, action.Host)
                {
                    Application = action.Kind == PanelActionKind.OpenIn
                        ? _applications.SingleOrDefault(application => application.Title == action.Application) : null
                });
                break;
        }
    }
 
    private static void LogPanelSettingsFailure(Exception error)
        => Console.Error.WriteLine($"Flyout settings failed ({error.GetType().Name}).");
 
    private static void OpenUri(string uri)
    {
        if (LinuxDesktop.g_app_info_launch_default_for_uri(uri, 0, out var error) == 0)
        {
            if (error != 0)
            {
                LinuxDesktop.g_error_free(error);
            }
            throw new InvalidOperationException("The desktop could not open the requested location. Configure a default browser or file manager.");
        }
    }
 
    private static nint CreateDialog(string title, string text)
    {
        var dialog = Gtk.gtk_dialog_new();
        Gtk.gtk_window_set_title(dialog, title);
        Gtk.gtk_window_set_modal(dialog, 1);
        var content = Gtk.gtk_dialog_get_content_area(dialog);
        Gtk.gtk_container_set_border_width(content, 20);
        var label = Gtk.gtk_label_new(text);
        Gtk.gtk_label_set_line_wrap(label, 1);
        Gtk.gtk_label_set_max_width_chars(label, 60);
        Gtk.gtk_container_add(content, label);
        return dialog;
    }
 
    private int RunDialog(nint dialog)
        => RunDialog(dialog, static dialog =>
        {
            Gtk.gtk_widget_show_all(dialog);
            return Gtk.gtk_dialog_run(dialog);
        });
 
    /// <summary>
    /// Runs a modal dialog while rejecting responses after shutdown is requested.
    /// </summary>
    internal int RunDialog(nint dialog, Func<nint, int> runDialog)
    {
        if (IsQuitting)
        {
            return -6;
        }
        _panel?.Hide();
        _dialog = dialog;
        try
        {
            var response = runDialog(dialog);
            // IPC/signals can request shutdown before GTK's timer processes it.
            // Do not commit an affirmative response during that interval.
            return IsQuitting ? -6 : response;
        }
        finally
        {
            _dialog = 0;
            if (_quitting)
            {
                Gtk.gtk_main_quit();
            }
        }
    }
 
    private bool Confirm(string title, string text, string affirmative)
    {
        var dialog = CreateDialog(title, text);
        try
        {
            Gtk.gtk_dialog_add_button(dialog, "Cancel", -6);
            Gtk.gtk_dialog_add_button(dialog, affirmative, -3);
            Gtk.gtk_dialog_set_default_response(dialog, -6);
            return RunDialog(dialog) == -3;
        }
        finally
        {
            Gtk.gtk_widget_destroy(dialog);
        }
    }
 
    private void ShowMessage(string title, string text)
    {
        var dialog = CreateDialog(title, text);
        try
        {
            Gtk.gtk_dialog_add_button(dialog, "Close", -7);
            RunDialog(dialog);
        }
        finally
        {
            Gtk.gtk_widget_destroy(dialog);
        }
    }
 
    private void CopyPath(string path)
    {
        if (_panel is not null || !LinuxDesktopEnvironment.IsWayland)
        {
            SetClipboardText(path);
            return;
        }
 
        // The native menu belongs to gnome-shell, not this client. Mutter rejects
        // clipboard selection from a client without keyboard focus. A user action
        // in our own dialog supplies that focus instead of silently losing the copy.
        // https://gitlab.gnome.org/GNOME/mutter/-/blob/main/src/wayland/meta-wayland-data-device.c
        var dialog = CreateDialog("Copy AppHost path", "Copy the AppHost folder path to the clipboard.");
        try
        {
            var entry = Gtk.gtk_entry_new();
            Gtk.gtk_entry_set_text(entry, path);
            Gtk.gtk_editable_set_editable(entry, 0);
            Gtk.gtk_editable_select_region(entry, 0, -1);
            Gtk.gtk_container_add(Gtk.gtk_dialog_get_content_area(dialog), entry);
            Gtk.gtk_dialog_add_button(dialog, "Cancel", -6);
            Gtk.gtk_dialog_add_button(dialog, "Copy", -3);
            Gtk.gtk_dialog_set_default_response(dialog, -3);
            if (RunDialog(dialog) == -3)
            {
                if (Gtk.gtk_window_has_toplevel_focus(dialog) == 0)
                {
                    throw new InvalidOperationException("Focus the Copy AppHost path window and try again.");
                }
                SetClipboardText(path);
            }
        }
        finally
        {
            Gtk.gtk_widget_destroy(dialog);
        }
    }
 
    private static void SetClipboardText(string text)
        => Gtk.gtk_clipboard_set_text(Gtk.gtk_clipboard_get(Gtk.gdk_atom_intern("CLIPBOARD", 0)), text, -1);
 
    private void ShowSettings()
    {
        var state = startup.Read();
        var dialog = CreateDialog(TraySettingsText.PanelTitle,
            $"Aspire Tray\n{TraySettingsText.GetVersionText()}\n{TraySettingsText.GetAboutDescription(preview: false)}\n\n{state.Detail}");
        try
        {
            var content = Gtk.gtk_dialog_get_content_area(dialog);
            var login = Gtk.gtk_check_button_new_with_label(TraySettingsText.PanelStartupOption);
            LinuxDesktop.gtk_toggle_button_set_active(login, state.Enabled ? 1 : 0);
            Gtk.gtk_widget_set_sensitive(login, state.CanEnable || state.Enabled ? 1 : 0);
            Gtk.gtk_container_add(content, login);
            Gtk.gtk_dialog_add_button(dialog, "Cancel", -6);
            Gtk.gtk_dialog_add_button(dialog, "Save", -3);
            Gtk.gtk_dialog_set_default_response(dialog, -6);
            if (RunDialog(dialog) == -3)
            {
                var enabled = Gtk.gtk_toggle_button_get_active(login) != 0;
                if (enabled != state.Enabled)
                {
                    startup.SetEnabled(enabled);
                }
            }
        }
        finally
        {
            Gtk.gtk_widget_destroy(dialog);
        }
    }
 
    public void Dispose()
    {
        Task.WhenAll(_desktopTasks).GetAwaiter().GetResult();
        if (_timer != 0)
        {
            Gtk.g_source_remove(_timer);
        }
        if (_idle != 0)
        {
            Gtk.g_source_remove(_idle);
        }
        _panelIcon?.Dispose();
        _panel?.Dispose();
        if (_indicator != 0)
        {
            Gtk.app_indicator_set_status(_indicator, 0);
            Gtk.g_object_unref(_indicator);
        }
        if (_menu != 0)
        {
            Gtk.gtk_widget_destroy(_menu);
            Gtk.g_object_unref(_menu);
        }
        if (_watcher != 0)
        {
            Gtk.g_object_unref(_watcher);
        }
        _icons?.Dispose();
        s_current = null;
    }
}
 
internal static partial class LinuxDesktop
{
    [LibraryImport("libgio-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int g_app_info_launch_default_for_uri(string uri, nint context, out nint error);
    [LibraryImport("libglib-2.0.so.0")] internal static partial void g_error_free(nint error);
    [LibraryImport("libgtk-3.so.0")] internal static partial void gtk_toggle_button_set_active(nint toggle, int active);
}