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 aNativeStatusIcon?. It returnsnullon 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
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:
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:
<ItemGroup>
<Content Include="trayTemplate.png" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>Then resolve it relative to the app base directory:
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:
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:
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:
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.
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.
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
dotnet new console -n HermesWeatherTray
cd HermesWeatherTray
dotnet add package HermesAdd a trayTemplate.png (a small monochrome PNG) to the project and mark it as content:
<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.
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.
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.
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:
dotnet runYou'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
| Capability | Windows | macOS | Linux |
|---|---|---|---|
| Status icon backend | Shell_NotifyIcon | NSStatusItem | libappindicator3 |
| Left-click handler | Yes | Yes | No (menu opens) |
| Double-click handler | Yes | No | No |
GetScreenPosition | Cursor position (approximate) | Button frame (exact) | Not applicable |
| Icon format | .ico or .png | .png (template images) | .png |
| Accessory mode | Hides from taskbar | Hides from dock | Hides 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
