// 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;
namespace Aspire.Tray;
internal sealed unsafe partial class TrayApplication
{
private FolderApplication[] _installedApplications = [];
private bool _menuStructureDirty;
private void RefreshMenu()
{
controller.PruneMissingPinnedAppHosts();
var state = controller.State;
var retained = _menuOpen || _modalDepth != 0 || _dispatchDepth != 0;
if (_artwork is null || (_dpiDirty && !retained))
{
var replacement = new Artwork(this, NativeMethods.GetDpiForWindow(_window));
_menu?.Dispose();
_menu = null;
var previous = _artwork;
_artwork = replacement;
_iconState = null;
_displayedState = null;
UpdateTrayIcon(state);
previous?.Dispose();
_dpiDirty = false;
}
UpdateTrayIcon(state);
if (ReferenceEquals(state, _displayedState) && (retained || !_menuStructureDirty))
{
return;
}
if (retained && _menu is not null)
{
UpdateRetainedMenu(_menu, state);
_menuStructureDirty = true;
}
else
{
var replacement = BuildMenu(state);
_menu?.Dispose();
_menu = replacement;
_menuStructureDirty = false;
}
_displayedState = state;
}
private NativeMenu BuildMenu(TrayViewState state)
{
var root = new NativeMenu(this);
try
{
if (state.ShowStatus)
{
root.StatusPosition = (uint)NativeMethods.GetMenuItemCount(root.Handle);
Append(root.Handle, NativeMethods.MfGrayed, 0, state.Status);
}
foreach (var host in state.AppHosts)
{
AddHost(root, root.Handle, host, recent: false, state.Discovery == DiscoveryState.Live);
}
Append(root.Handle, NativeMethods.MfSeparator, 0, null);
var recent = AddSubmenu(root.Handle, "Recently opened");
root.RecentHandle = recent;
foreach (var host in state.RecentAppHosts)
{
AddHost(root, recent, host, recent: true, state.Discovery == DiscoveryState.Live);
}
if (state.RecentAppHosts.Count == 0)
{
Append(recent, NativeMethods.MfGrayed, 0, "No recently opened AppHosts");
}
Append(recent, NativeMethods.MfSeparator, 0, null);
root.ClearCommand = AddAction(root, recent, "Clear recently opened...", new(ActionKind.ClearRecent), state.CanClearRecent);
Append(root.Handle, NativeMethods.MfSeparator, 0, null);
var documentation = AddAction(root, root.Handle, "Documentation", new(ActionKind.Documentation), true);
SetMenuBitmap(root.Handle, documentation, _artwork!.DocumentationBitmap, byPosition: false);
var settings = AddAction(root, root.Handle, "Settings...", new(ActionKind.Settings), true);
SetSettingsMenuLabel(root.Handle, settings);
SetMenuBitmap(root.Handle, settings, _artwork.SettingsBitmap, byPosition: false);
Append(root.Handle, NativeMethods.MfSeparator, 0, null);
AddAction(root, root.Handle, "Quit Aspire", new(ActionKind.Quit), true);
return root;
}
catch
{
root.Dispose();
throw;
}
}
private void AddHost(NativeMenu root, nint parent, AppHostMenuItem host, bool recent, bool discoveryAvailable)
{
var position = (uint)NativeMethods.GetMenuItemCount(parent);
var title = AppHostPresentation.GetCompactMenuLabel(host);
var submenu = AddSubmenu(parent, title);
SetMenuBitmap(parent, position, _artwork!.Status(GetMenuStatus(host, discoveryAvailable)), byPosition: true);
var row = new HostRow(parent, position, submenu, host.Id, recent, title)
{
DetailsText = AppHostPresentation.GetMenuDetailsText(host.Id.AppHostPath, host.Subtitle)
};
root.Rows.Add(row);
if (host.IsRunning)
{
row.Dashboard = AddAction(root, submenu, "Open dashboard",
new(ActionKind.Dashboard, host.Id), host.CanOpenDashboard);
SetDefaultMenuItem(submenu, row.Dashboard);
row.Stop = AddAction(root, submenu, host.IsStopping ? "Stopping..." : StopMenuLabel,
new(ActionKind.Stop, host.Id), host.CanStop);
}
else
{
row.Start = AddAction(root, submenu, host.IsStarting ? "Starting..." : "Start AppHost",
new(ActionKind.Start, host.Id), host.CanStart);
}
row.Pin = AddAction(root, submenu, host.IsPinned ? "Unpin AppHost" : "Pin AppHost", new(ActionKind.TogglePin, host.Id), true);
Append(submenu, NativeMethods.MfSeparator, 0, null);
row.Explorer = AddAction(root, submenu, "Show in File Explorer", new(ActionKind.Explorer, host.Id), true);
row.CopyPath = AddAction(root, submenu, "Copy path", new(ActionKind.CopyPath, host.Id), true);
row.OpenIn = AddSubmenu(submenu, "Open in");
foreach (var application in _installedApplications)
{
var id = AddAction(root, row.OpenIn, application.Title, new(ActionKind.OpenIn, host.Id, application), true);
row.OpenCommands.Add(id);
}
if (_installedApplications.Length == 0)
{
Append(row.OpenIn, NativeMethods.MfGrayed, 0, "No supported applications installed");
}
Append(submenu, NativeMethods.MfSeparator, 0, null);
row.StatusPosition = (uint)NativeMethods.GetMenuItemCount(submenu);
Append(submenu, NativeMethods.MfGrayed, 0, AppHostPresentation.GetMenuDetailsLabel(GetMenuStatusText(host, discoveryAvailable)));
row.DetailsPosition = (uint)NativeMethods.GetMenuItemCount(submenu);
Append(submenu, NativeMethods.MfGrayed, 0, AppHostPresentation.GetPathLabel(host.Id.AppHostPath));
}
private void UpdateRetainedMenu(NativeMenu menu, TrayViewState state)
{
if (menu.StatusPosition is uint statusPosition)
{
UpdateItem(menu.Handle, statusPosition, true, state.ShowStatus ? state.Status : "Aspire", false);
}
foreach (var row in menu.Rows)
{
// A row stays bound to the original lifetime even if a PID/path is reused while
// TrackPopupMenuEx or MessageBox pumps a nested loop. Never rebind a command ID.
var host = (row.Recent ? state.RecentAppHosts : state.AppHosts).SingleOrDefault(item => item.Id == row.Id);
UpdateItem(row.Parent, row.Position, true,
host is null ? row.Title : AppHostPresentation.GetCompactMenuLabel(host),
host is not null);
SetMenuBitmap(row.Parent, row.Position,
_artwork!.Status(GetMenuStatus(host, state.Discovery == DiscoveryState.Live)), byPosition: true);
row.DetailsText = AppHostPresentation.GetMenuDetailsText(row.Id.AppHostPath, host?.Subtitle ?? "AppHost no longer available");
UpdateItem(row.Submenu, row.StatusPosition, true,
AppHostPresentation.GetMenuDetailsLabel(GetMenuStatusText(host, state.Discovery == DiscoveryState.Live)), false);
UpdateItem(row.Submenu, row.DetailsPosition, true, AppHostPresentation.GetPathLabel(row.Id.AppHostPath), false);
UpdateAction(row.Submenu, row.Dashboard, "Open dashboard", host?.CanOpenDashboard == true);
UpdateAction(row.Submenu, row.Stop, host?.IsStopping == true ? "Stopping..." : StopMenuLabel, host?.CanStop == true);
UpdateAction(row.Submenu, row.Start, host?.IsStarting == true ? "Starting..." : "Start AppHost", host?.CanStart == true);
UpdateAction(row.Submenu, row.Pin, host?.IsPinned == true ? "Unpin AppHost" : "Pin AppHost", host is not null);
UpdateAction(row.Submenu, row.Explorer, "Show in File Explorer", host is not null);
UpdateAction(row.Submenu, row.CopyPath, "Copy path", host is not null);
foreach (var id in row.OpenCommands)
{
UpdateAction(row.OpenIn, id, menu.Commands[id].Application!.Title, host is not null);
}
}
UpdateAction(menu.RecentHandle, menu.ClearCommand, "Clear recently opened...", state.CanClearRecent);
if (_menuOpen)
{
UpdateMenuTooltip();
// SetMenuItemInfo changes the model, but an already visible popup also needs
// painting. Limit invalidation to this UI thread's standard menu windows.
NativeCallException.Require(NativeMethods.EnumThreadWindows(NativeMethods.GetCurrentThreadId(), &RedrawPopup, 0) != 0,
"EnumThreadWindows(redraw menus)");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
private static int RedrawPopup(nint window, nint parameter)
{
try
{
char* name = stackalloc char[32];
var count = NativeMethods.GetClassName(window, name, 32);
NativeCallException.Require(count != 0, "GetClassNameW(menu)");
if (new ReadOnlySpan<char>(name, count).SequenceEqual("#32768"))
{
NativeCallException.Require(NativeMethods.RedrawWindow(window, 0, 0, 0x101) != 0, "RedrawWindow(menu)");
}
return 1;
}
catch (Exception ex)
{
if (s_current is { } application)
{
application._callbackFailure ??= ex;
Program.Log($"Menu redraw callback failed: {ex.Message}");
}
return 0;
}
}
private string StopMenuLabel => controller.ConfirmStop ? "Stop AppHost..." : "Stop AppHost";
private static string GetMenuStatusText(AppHostMenuItem? host, bool discoveryAvailable) => host switch
{
null => "AppHost no longer available",
_ when !discoveryAvailable => "Discovery unavailable",
{ Error: not null } => host.Error,
{ IsStarting: true } => "Starting AppHost...",
{ IsStopping: true } => "Stopping AppHost...",
{ IsRunning: false } => "Stopped",
{ Health: AppHostHealth.Healthy } => "Running - all resources healthy",
{ Health: AppHostHealth.Warning } => "Running - resources need attention",
{ Health: AppHostHealth.Unhealthy } => "Running - unhealthy resources",
_ => "Running - resource health unknown"
};
private static nint AddSubmenu(nint parent, string title)
{
var submenu = NativeMethods.CreatePopupMenu();
NativeCallException.Require(submenu != 0, "CreatePopupMenu");
try
{
Append(parent, NativeMethods.MfPopup, (nuint)submenu, title);
return submenu;
}
catch
{
if (NativeMethods.DestroyMenu(submenu) == 0)
{
Program.Log("DestroyMenu failed for an unattached submenu.");
}
throw;
}
}
private static uint AddAction(NativeMenu menu, nint parent, string title, ActionTarget target, bool enabled)
{
var id = (uint)menu.Commands.Count + 100;
menu.Commands.Add(id, target);
Append(parent, enabled ? 0u : NativeMethods.MfGrayed, id, title);
UpdateItem(parent, id, false, title, enabled);
return id;
}
private static string? Literal(string? text)
// Win32 treats '&' as a mnemonic and tabs as accelerator separators.
// Preserve paths such as C:\src\A&B\AppHost.cs as literal display text.
=> text?.Replace("&", "&&", StringComparison.Ordinal).Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
private static void Append(nint menu, uint flags, nuint id, string? text)
=> NativeCallException.Require(NativeMethods.AppendMenu(menu, flags, id, Literal(text)) != 0, "AppendMenuW");
private static void UpdateAction(nint menu, uint id, string title, bool enabled)
{
if (id != 0)
{
UpdateItem(menu, id, false, title, enabled);
}
}
private static void SetMenuBitmap(nint menu, uint item, nint bitmap, bool byPosition)
{
var info = new NativeMethods.MenuItemInfo
{
Size = (uint)sizeof(NativeMethods.MenuItemInfo), Mask = NativeMethods.MiimBitmap, Bitmap = bitmap
};
NativeCallException.Require(NativeMethods.SetMenuItemInfo(menu, item, byPosition ? 1 : 0, ref info) != 0, "SetMenuItemInfoW(menu bitmap)");
}
private static void SetDefaultMenuItem(nint menu, uint command)
{
NativeCallException.Require(NativeMethods.SetMenuDefaultItem(menu, command, 0) != 0, "SetMenuDefaultItem");
}
private static void UpdateItem(nint menu, uint id, bool byPosition, string title, bool enabled)
{
var previous = new NativeMethods.MenuItemInfo
{
Size = (uint)sizeof(NativeMethods.MenuItemInfo), Mask = NativeMethods.MiimState
};
NativeCallException.Require(NativeMethods.GetMenuItemInfo(menu, id, byPosition ? 1 : 0, ref previous) != 0, "GetMenuItemInfoW(update)");
var wasDefault = (previous.State & 0x1000) != 0;
if (wasDefault)
{
// Default-item ownership is menu-level state. Clear and restore it through
// the dedicated API instead of resubmitting MFS_DEFAULT with a label update.
// https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-setmenudefaultitem
NativeCallException.Require(NativeMethods.SetMenuDefaultItem(menu, uint.MaxValue, 0) != 0, "SetMenuDefaultItem(clear)");
}
fixed (char* text = Literal(title))
{
var info = new NativeMethods.MenuItemInfo
{
Size = (uint)sizeof(NativeMethods.MenuItemInfo),
Mask = NativeMethods.MiimString | NativeMethods.MiimState,
Text = text, State = (previous.State & ~(3u | 0x1000u)) | (enabled ? 0u : NativeMethods.MfGrayed)
};
NativeCallException.Require(NativeMethods.SetMenuItemInfo(menu, id, byPosition ? 1 : 0, ref info) != 0, "SetMenuItemInfoW");
if (wasDefault)
{
NativeCallException.Require(NativeMethods.SetMenuDefaultItem(menu, id, byPosition ? 1u : 0u) != 0,
"SetMenuDefaultItem(restore)");
}
}
}
private void ShowMenu(nuint coordinates)
{
var point = new NativeMethods.Point
{
X = unchecked((short)(coordinates & 0xFFFF)),
Y = unchecked((short)((coordinates >> 16) & 0xFFFF))
};
if (point.X == -1 && point.Y == -1)
{
NativeCallException.Require(NativeMethods.GetCursorPos(out point) != 0, "GetCursorPos");
}
PrepareMenu(point);
TrackMenu(_menu!.Handle, point);
}
private void PrepareMenu(NativeMethods.Point point)
{
// A hidden top-level window otherwise stays on the primary monitor forever. Move its
// zero-sized bounds to the tray interaction so per-monitor menu/icon DPI follows it.
NativeCallException.Require(NativeMethods.SetWindowPos(_window, 0, point.X, point.Y, 0, 0, 0x1 | 0x4 | 0x10) != 0, "SetWindowPos(tray monitor)");
RefreshMenu();
}
private void TrackMenu(nint popup, NativeMethods.Point point)
{
var menu = _menu!;
// Foreground activation is a request, not a prerequisite for owning a popup menu.
// Background smoke runs can be denied activation by Windows' foreground lock.
if (NativeMethods.SetForegroundWindow(_window) == 0)
{
Program.Log("Windows kept the Aspire tray menu in the background.");
}
_menuOpen = true;
try
{
Marshal.SetLastPInvokeError(0);
var selected = NativeMethods.TrackPopupMenuEx(popup,
NativeMethods.TpmReturnCmd | NativeMethods.TpmRightButton,
point.X, point.Y, _window, 0);
var error = Marshal.GetLastPInvokeError();
if (selected == 0 && error != 0)
{
throw new NativeCallException("TrackPopupMenuEx", error);
}
// Capture the immutable target before running anything that can pump messages.
if (!_quitRequested && menu.Commands.TryGetValue(selected, out var target))
{
Dispatch(target);
}
}
finally
{
HideMenuTooltip();
_menuOpen = false;
_displayedState = null;
if (!_quitRequested)
{
RefreshMenu();
}
}
// Required to dismiss notification-area menus reliably on subsequent opens.
// https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-trackpopupmenuex
NativeCallException.Require(NativeMethods.PostMessage(_window, NativeMethods.WmNull, 0, 0) != 0, "PostMessageW");
}
private void PinContextRow(nint menu, uint position)
{
var row = _menu?.Rows.SingleOrDefault(row => row.Parent == menu && row.Position == position);
var host = row is null ? null : controller.State.AppHosts.SingleOrDefault(host => host.Id == row.Id);
if (host is null || !host.IsRunning)
{
return;
}
HideMenuTooltip();
using var context = new NativeMenu(this);
var command = AddAction(context, context.Handle, host.IsPinned ? "Unpin AppHost" : "Pin AppHost", new(ActionKind.TogglePin, host.Id), true);
NativeCallException.Require(NativeMethods.GetCursorPos(out var point) != 0, "GetCursorPos");
Marshal.SetLastPInvokeError(0);
var selected = NativeMethods.TrackPopupMenuEx(context.Handle,
NativeMethods.TpmReturnCmd | NativeMethods.TpmNonotify | NativeMethods.TpmRightButton | 0x1,
point.X, point.Y, _window, 0); // TPM_RECURSE permits this secondary popup.
var error = Marshal.GetLastPInvokeError();
if (selected == 0 && error != 0)
{
throw new NativeCallException("TrackPopupMenuEx(pin)", error);
}
if (!_quitRequested && selected == command)
{
Dispatch(context.Commands[command]);
}
}
private enum ActionKind { Dashboard, Stop, Start, TogglePin, Explorer, CopyPath, OpenIn, ClearRecent, Documentation, Settings, Quit }
private sealed record ActionTarget(ActionKind Kind, AppHostId Id = default, FolderApplication? Application = null);
private sealed class HostRow(nint parent, uint position, nint submenu, AppHostId id, bool recent, string title)
{
internal nint Parent { get; } = parent;
internal uint Position { get; } = position;
internal nint Submenu { get; } = submenu;
internal AppHostId Id { get; } = id;
internal bool Recent { get; } = recent;
internal string Title { get; } = title;
internal uint StatusPosition { get; set; }
internal uint DetailsPosition { get; set; }
internal required string DetailsText { get; set; }
internal uint Dashboard { get; set; }
internal uint Stop { get; set; }
internal uint Start { get; set; }
internal uint Pin { get; set; }
internal uint Explorer { get; set; }
internal uint CopyPath { get; set; }
internal nint OpenIn { get; set; }
internal List<uint> OpenCommands { get; } = [];
}
private sealed class NativeMenu(TrayApplication owner) : IDisposable
{
private bool _disposed;
internal nint Handle { get; } = Create();
internal Dictionary<uint, ActionTarget> Commands { get; } = [];
internal List<HostRow> Rows { get; } = [];
internal uint? StatusPosition { get; set; }
internal nint RecentHandle { get; set; }
internal uint ClearCommand { get; set; }
private static nint Create()
{
var handle = NativeMethods.CreatePopupMenu();
NativeCallException.Require(handle != 0, "CreatePopupMenu");
return handle;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
owner.Cleanup(NativeMethods.DestroyMenu(Handle) != 0, "DestroyMenu");
}
}
}
}