File: TrayApplication.Actions.cs
Web Access
Project: src\src\Aspire.Tray\Windows\Aspire.Tray.Windows.csproj (aspire-tray)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Tray;
 
internal sealed partial class TrayApplication
{
    private void Dispatch(ActionTarget target)
    {
        if (_quitRequested)
        {
            return;
        }
        HideMenuTooltip();
        _dispatchDepth++;
        try
        {
            switch (target.Kind)
            {
                case ActionKind.Dashboard:
                    OpenUrl(controller.GetDashboardUri(target.Id));
                    break;
                case ActionKind.Stop:
                    if (!RequireCurrentRow(target.Id).CanStop)
                    {
                        throw new InvalidOperationException("Stop is no longer available for the selected AppHost.");
                    }
                    var host = controller.RequireLiveInstance(target.Id);
                    if (ConfirmStop(host))
                    {
                        // The controller revalidates the full lifetime after the modal loop.
                        controller.RequestStop(target.Id);
                        // Save only after dispatch accepts the exact instance. A canceled or
                        // stale action must not suppress later warnings.
                        if (_suppressStopConfirmation)
                        {
                            controller.SetConfirmStop(false);
                        }
                    }
                    break;
                case ActionKind.Start:
                    RequireCurrentRow(target.Id);
                    if (RequireProjectFile(target.Id.AppHostPath))
                    {
                        controller.RequestStart(target.Id.AppHostPath);
                    }
                    break;
                case ActionKind.TogglePin:
                    var row = RequireCurrentRow(target.Id);
                    controller.SetPinned(row.Id.AppHostPath, !row.IsPinned);
                    break;
                case ActionKind.CopyPath:
                    RequireCurrentRow(target.Id);
                    CopyPath(Path.GetDirectoryName(target.Id.AppHostPath)!);
                    break;
                case ActionKind.Explorer:
                case ActionKind.OpenIn:
                    RequireCurrentRow(target.Id);
                    if (RequireProjectFile(target.Id.AppHostPath))
                    {
                        if (smokeSeconds is not null && !_interactiveSmoke)
                        {
                            throw new InvalidOperationException("External application launch is disabled in smoke mode.");
                        }
                        if (target.Kind == ActionKind.Explorer)
                        {
                            FolderApplication.ShowInExplorer(target.Id.AppHostPath);
                        }
                        else
                        {
                            target.Application!.Open(Path.GetDirectoryName(target.Id.AppHostPath)!);
                        }
                    }
                    break;
                case ActionKind.ClearRecent:
                    if (Confirm("Clear Recently Opened", "Clear all recently opened AppHosts?\n\nThis action is irreversible. Pinned AppHosts will be kept."))
                    {
                        controller.ClearRecent();
                    }
                    break;
                case ActionKind.Documentation:
                    OpenUrl(new Uri("https://aspire.dev"));
                    break;
                case ActionKind.Settings:
                    ShowSettings();
                    break;
                case ActionKind.Quit:
                    RequestQuit();
                    break;
            }
        }
        catch (Exception ex)
        {
            // This boundary covers native commands and their nested modal loops.
            controller.ReportActionError(ex.Message);
            Program.Log($"Tray action failed: {ex.Message}");
            if (smokeSeconds is not null && !_interactiveSmoke)
            {
                if (ErrorForSmoke is null)
                {
                    throw;
                }
                ErrorForSmoke(ex);
            }
            else
            {
                ShowMessage("Aspire", ex.Message, NativeMethods.MbIconError);
            }
        }
        finally
        {
            _dispatchDepth--;
            RequestRefresh();
        }
    }
 
    private AppHostMenuItem RequireCurrentRow(AppHostId id)
    {
        var state = controller.State;
        return state.AppHosts.Concat(state.RecentAppHosts).SingleOrDefault(host => host.Id == id)
            ?? throw new InvalidOperationException("The selected AppHost is no longer available.");
    }
 
    private bool RequireProjectFile(string path)
    {
        if (!TrayAppHostPath.IsMissing(path))
        {
            TrayAppHostPath.RequireExistingFile(path);
            return true;
        }
        if (controller.State.AppHosts.Any(host => host.IsPinned && !host.IsRunning && TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, path)))
        {
            controller.PruneMissingPinnedAppHosts();
        }
        else if (controller.State.RecentAppHosts.Any(host => TrayAppHostPath.Comparer.Equals(host.Id.AppHostPath, path)))
        {
            if (Confirm("AppHost not found", $"This AppHost no longer exists:\n\n{path}\n\nRemove it from Recently Opened?"))
            {
                controller.RemoveRecent(path);
            }
        }
        else
        {
            throw new FileNotFoundException("The selected AppHost source file no longer exists.", path);
        }
        return false;
    }
 
    private bool Confirm(string title, string detail)
    {
        var previousOwner = _modalOwner;
        _modalOwner = _settingsWindow != 0 ? _settingsWindow : _window;
        _modalDepth++;
        try
        {
            if (smokeSeconds is not null && !_interactiveSmoke)
            {
                var accept = ConfirmForSmoke?.Invoke(title, detail, NativeMethods.SafeConfirmation)
                    ?? throw new InvalidOperationException("Smoke confirmation handler is missing.");
                _smokeDialog = new(2, accept ? 1 : 2);
            }
            var result = NativeMethods.MessageBox(_modalOwner, detail, title, NativeMethods.SafeConfirmation);
            NativeCallException.Require(result != 0, "MessageBoxW(confirm)");
            return result == 1 && !_quitRequested;
        }
        finally
        {
            _smokeDialog = null;
            DialogReadyForSmoke = null;
            _modalDepth--;
            _modalOwner = previousOwner;
            RequestRefresh();
        }
    }
 
    private void ShowMessage(string title, string detail, uint flags)
    {
        var previousOwner = _modalOwner;
        _modalOwner = _settingsWindow != 0 ? _settingsWindow : _window;
        _modalDepth++;
        try
        {
            if (smokeSeconds is not null && !_interactiveSmoke)
            {
                // MB_OK reports IDOK as its default, but its sole button uses IDCANCEL
                // so Escape can dismiss it. Sending IDOK leaves the native dialog open.
                _smokeDialog = new(1, 2);
            }
            NativeCallException.Require(NativeMethods.MessageBox(_modalOwner, detail, title, flags) != 0, "MessageBoxW");
        }
        finally
        {
            _smokeDialog = null;
            _modalDepth--;
            _modalOwner = previousOwner;
            RequestRefresh();
        }
    }
 
    private void OpenUrl(Uri uri)
    {
        if (smokeSeconds is not null)
        {
            (OpenUrlForSmoke ?? throw new InvalidOperationException("Smoke URL handler is missing."))(uri);
            return;
        }
        // ShellExecute receives only a controller-validated http(s) URL, never a command string.
        var result = NativeMethods.ShellExecute(_window, "open", uri.AbsoluteUri, null, null, 1);
        if (result <= 32)
        {
            throw new NativeCallException("ShellExecuteW(open URL)", (int)result);
        }
    }
}