Skip to content

Tray Applications

Hermes supports system tray / menu bar applications on Windows, macOS, and Linux. The same fluent API (HermesApplication.CreateStatusIcon()) gives you a native status icon backed by Shell_NotifyIcon on Windows, NSStatusItem on macOS, and libappindicator3 on Linux.

Overview

A tray app typically has no main window on the taskbar or dock — it lives in the system tray and shows a small window on demand. Building one with Hermes comes down to three pieces:

  • HermesApplication.SetAccessoryMode() — hides the app from the taskbar (Windows/Linux) and dock (macOS), and keeps the process alive when the last window closes. Must be called before any windows are created.
  • HermesApplication.CreateStatusIcon() — returns a NativeStatusIcon?. It returns null on platforms where the status icon can't be created (e.g. Linux without libappindicator3), so always null-check.
  • HermesApplication.Shutdown() — disposes all status icons on exit.

Quick Reference

csharp
using Hermes;

HermesApplication.SetAccessoryMode();

if (HermesApplication.CreateStatusIcon() is { } tray)
{
    tray.SetIcon("trayTemplate.png")
        .SetTooltip("My App")
        .SetMenu(menu =>
        {
            menu.AddItem("Open", "tray.open")
                .AddSeparator()
                .AddItem("Quit", "tray.quit");
        });

    tray.Menu!.ItemClicked += id =>
    {
        if (id == "tray.quit") tray.Dispose();
    };

    tray.Show();
}

HermesApplication.Shutdown();

Configuring the Icon

NativeStatusIcon exposes two ways to set the icon:

csharp
tray.SetIcon("path/to/icon.png");        // file path — .png or .ico
tray.SetIconFromStream(embeddedStream);  // any Stream (embedded resource, memory)

macOS template images

On macOS, name your icon file with a Template suffix (e.g. trayTemplate.png) and use a monochrome PNG. NSStatusItem will automatically adapt it to light and dark menu bars. Plain color images work too, but they won't adapt to theme changes.

For a SetIcon call from the output directory, copy the file as content in your .csproj:

xml
<ItemGroup>
  <Content Include="trayTemplate.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Then resolve it relative to the app base directory:

csharp
var iconPath = Path.Combine(AppContext.BaseDirectory, "trayTemplate.png");
tray.SetIcon(iconPath);

Building the Menu

Configure the context menu via SetMenu, using the same fluent builder as the native menu bar:

csharp
tray.SetMenu(menu =>
{
    menu.AddItem("Weather", "tray.title", item => item.WithEnabled(false))
        .AddSeparator()
        .AddItem("Refresh", "tray.refresh")
        .AddSubmenu("Units", "tray.units", units =>
        {
            units.AddItem("Fahrenheit", "units.f", i => i.WithChecked(true));
            units.AddItem("Celsius", "units.c");
        })
        .AddSeparator()
        .AddItem("Quit", "tray.quit");
});

Every item has a unique itemId string, which you handle via the ItemClicked event:

csharp
tray.Menu!.ItemClicked += itemId =>
{
    switch (itemId)
    {
        case "tray.refresh": /* ... */ break;
        case "units.f":      /* ... */ break;
        case "units.c":      /* ... */ break;
        case "tray.quit":    tray.Dispose(); break;
    }
};

You can also update items at runtime — Label, IsEnabled, and IsChecked setters push to the backend immediately:

csharp
tray.Menu!["tray.refresh"].IsEnabled = false;
tray.Menu!["units.f"].IsChecked = false;
tray.Menu!["units.c"].IsChecked = true;

Handling Clicks

OnClicked fires on a left-click of the tray icon itself. OnDoubleClicked is Windows-only.

csharp
tray.OnClicked(() =>
{
    if (window.IsVisible) window.Hide();
    else window.Show();
});

Linux click behavior

On Linux, libappindicator3 captures icon clicks and opens the context menu automatically — OnClicked does not fire. Put anything click-triggered in the menu itself so it works across all three platforms.

Positioning a Window Near the Tray

GetScreenPosition() returns (x, y, width, height) in screen coordinates. On macOS this is the exact button frame; on Windows it's the cursor position when called; on Linux it's not applicable and returns zeros.

csharp
void PositionWindowUnderTray(HermesWindow window, NativeStatusIcon tray)
{
    var (ix, iy, iw, ih) = tray.GetScreenPosition();
    if (ix == 0 && iy == 0 && iw == 0 && ih == 0)
        return; // position unknown, leave the window where it is

    const int windowWidth = 320;
    int x = ix + (iw / 2) - (windowWidth / 2);
    int y = iy + ih;
    window.Position = (x, y);
}

Complete Example: Weather Tray

This example builds a small tray app that shows the current forecast from the US National Weather Service. The NWS API is free, requires no signup, and returns clean JSON — a good fit for sample code.

Project Setup

bash
dotnet new console -n HermesWeatherTray
cd HermesWeatherTray
dotnet add package Hermes

Add a trayTemplate.png (a small monochrome PNG) to the project and mark it as content:

xml
<ItemGroup>
  <Content Include="trayTemplate.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Weather Service

NWS requires a User-Agent header that identifies your app — requests without one are rejected. The service also keeps a tiny in-memory cache so the "Refresh" menu item does something visible.

csharp
using System.Net.Http.Json;
using System.Text.Json.Serialization;

public sealed class WeatherService
{
    private const string ForecastUrl =
        "https://api.weather.gov/gridpoints/TOP/31,80/forecast";

    private readonly HttpClient _http;
    private ForecastPeriod? _cached;

    public WeatherService()
    {
        _http = new HttpClient();
        _http.DefaultRequestHeaders.UserAgent.ParseAdd(
            "HermesWeatherTray/1.0 (you@example.com)");
    }

    public bool WasCacheHit { get; private set; }

    public async Task<ForecastPeriod?> GetCurrentPeriodAsync()
    {
        if (_cached is not null)
        {
            WasCacheHit = true;
            return _cached;
        }

        WasCacheHit = false;
        var response = await _http.GetFromJsonAsync<NwsForecastResponse>(ForecastUrl);
        _cached = response?.Properties?.Periods?.FirstOrDefault();
        return _cached;
    }

    public void ClearCache() => _cached = null;
}

public sealed class NwsForecastResponse
{
    [JsonPropertyName("properties")]
    public NwsProperties? Properties { get; set; }
}

public sealed class NwsProperties
{
    [JsonPropertyName("periods")]
    public List<ForecastPeriod>? Periods { get; set; }
}

public sealed class ForecastPeriod
{
    [JsonPropertyName("name")]
    public string? Name { get; set; }

    [JsonPropertyName("temperature")]
    public int Temperature { get; set; }

    [JsonPropertyName("temperatureUnit")]
    public string? TemperatureUnit { get; set; }

    [JsonPropertyName("shortForecast")]
    public string? ShortForecast { get; set; }
}

AOT note

This sample uses reflection-based System.Text.Json, which emits trim/AOT warnings when you publish with PublishAot=true. For AOT-compatible deserialization, declare a [JsonSerializable(typeof(NwsForecastResponse))] partial JsonSerializerContext and pass it to GetFromJsonAsync. This is a standard .NET pattern and isn't specific to Hermes — see System.Text.Json source generation.

Window

A chromeless window with a small HTML shell. The C# side pushes forecast updates via window.SendMessage, and the web side renders them into the DOM.

csharp
using Hermes;

public static class WeatherWindow
{
    public static HermesWindow Create() =>
        new HermesWindow()
            .SetTitle("Weather")
            .SetSize(320, 200)
            .SetChromeless(true)
            .SetResizable(false)
            .SetTopMost(true)
            .LoadHtml(Html);

    private const string Html = """
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8" />
      <style>
        body {
          margin: 0;
          padding: 20px;
          font-family: -apple-system, 'Segoe UI', sans-serif;
          background: #1a1a2e;
          color: #e0e0e0;
          border-radius: 10px;
        }
        h1 { font-size: 18px; margin: 0 0 8px; }
        .temp { font-size: 42px; font-weight: 700; }
        .summary { color: #a0a0c0; margin-top: 4px; }
        .status { font-size: 11px; color: #666; margin-top: 12px; }
      </style>
    </head>
    <body>
      <h1 id="period">Loading...</h1>
      <div class="temp" id="temp"></div>
      <div class="summary" id="summary"></div>
      <div class="status" id="status"></div>
      <script>
        window.external.receiveMessage(msg => {
          const data = JSON.parse(msg);
          document.getElementById('period').textContent = data.name;
          document.getElementById('temp').textContent = data.temp + '°' + data.unit;
          document.getElementById('summary').textContent = data.summary;
          document.getElementById('status').textContent =
            data.cached ? 'Cached' : 'Fetched just now';
        });
      </script>
    </body>
    </html>
    """;
}

Program

Ties everything together: accessory mode, the tray icon, menu handling, click-to-toggle, and the PositionWindowUnderTray helper.

csharp
using System.Text.Json;
using Hermes;
using Hermes.StatusIcon;

HermesApplication.SetAccessoryMode();

var service = new WeatherService();
var window = WeatherWindow.Create();

// macOS requires NSApplication to be initialized before creating the status
// icon. Showing and hiding the window once bootstraps the platform runtime.
window.Show();
window.Hide();
var isVisible = false;

var iconPath = Path.Combine(AppContext.BaseDirectory, "trayTemplate.png");
if (HermesApplication.CreateStatusIcon() is not { } tray)
{
    Console.WriteLine("System tray not supported on this platform.");
    window.Show();
    window.WaitForClose();
    return;
}

tray.SetIcon(iconPath)
    .SetTooltip("Weather")
    .SetMenu(menu =>
    {
        menu.AddItem("Weather — Topeka, KS", "tray.title", i => i.WithEnabled(false))
            .AddSeparator()
            .AddItem("Refresh", "tray.refresh")
            .AddItem("Clear Cache", "tray.clear")
            .AddSeparator()
            .AddItem("Quit", "tray.quit");
    })
    .OnClicked(() =>
    {
        if (isVisible)
        {
            window.Hide();
            isVisible = false;
        }
        else
        {
            PositionWindowUnderTray(window, tray);
            window.Show();
            isVisible = true;
            _ = RefreshAsync();
        }
    });

tray.Menu!.ItemClicked += async id =>
{
    switch (id)
    {
        case "tray.refresh":
            await RefreshAsync();
            break;
        case "tray.clear":
            service.ClearCache();
            break;
        case "tray.quit":
            tray.Dispose();
            window.Close();
            break;
    }
};

tray.Show();
PositionWindowUnderTray(window, tray);

window.WaitForClose();
HermesApplication.Shutdown();

async Task RefreshAsync()
{
    var forecast = await service.GetCurrentPeriodAsync();
    if (forecast is null) return;

    var payload = JsonSerializer.Serialize(new
    {
        name = forecast.Name,
        temp = forecast.Temperature,
        unit = forecast.TemperatureUnit,
        summary = forecast.ShortForecast,
        cached = service.WasCacheHit
    });

    window.Invoke(() => window.SendMessage(payload));
}

static void PositionWindowUnderTray(HermesWindow window, NativeStatusIcon tray)
{
    var (ix, iy, iw, ih) = tray.GetScreenPosition();
    if (ix == 0 && iy == 0 && iw == 0 && ih == 0) return;

    const int windowWidth = 320;
    int x = ix + (iw / 2) - (windowWidth / 2);
    int y = iy + ih;
    window.Position = (x, y);
}

Run it:

bash
dotnet run

You'll see the tray icon appear in the menu bar (macOS) or notification area (Windows). Click it to toggle the forecast window, or use the context menu to refresh or quit.

Platform Differences

CapabilityWindowsmacOSLinux
Status icon backendShell_NotifyIconNSStatusItemlibappindicator3
Left-click handlerYesYesNo (menu opens)
Double-click handlerYesNoNo
GetScreenPositionCursor position (approximate)Button frame (exact)Not applicable
Icon format.ico or .png.png (template images).png
Accessory modeHides from taskbarHides from dockHides from taskbar

See Platform Notes for broader platform guidance.

Next Steps

  • Menus — the native menu bar uses the same fluent builder
  • Windows — chromeless windows, positioning, and lifecycle
  • Platform Notes — platform-specific behavior