System Tray
The Native SDK supports system tray icons with menus. Tray menu items can dispatch explicit command names with source = .tray; items without a command dispatch the compatibility command name "tray.action".
Tray support is currently implemented on macOS and Windows system WebView hosts. Linux returns UnsupportedService until a portable status notifier implementation is selected.
On macOS, title renders the tray as a menu-bar extra: a titled NSStatusItem with variable width. When both icon_path and title are empty, the button falls back to the app name's first letter.
TrayOptions
| Field | Type | Default |
|---|---|---|
icon_path | []const u8 | "" |
title | []const u8 | "" |
tooltip | []const u8 | "" |
items | []const TrayMenuItem | &. |
presentation | TrayPresentation | . |
activation_command | []const u8 | "" |
alternate_activation_command | []const u8 | "" |
open_command | []const u8 | "" |
TrayMenuItem
| Field | Type | Default |
|---|---|---|
id | TrayItemId (u32) | 0 |
label | []const u8 | "" |
command | []const u8 | "" |
separator | bool | false |
enabled | bool | true |
detail | []const u8 | "" |
role | TrayItemRole | .command |
key | []const u8 | "" |
modifiers | ShortcutModifiers | . |
PlatformServices methods
Use the runtime methods from app code:
runtime.createStatusItem(id, options)-- create a status item under a stable non-zero idruntime.updateStatusItemShell(id, shell)-- update icon, tooltip, visibility, and activation/open commands in placeruntime.updateStatusItemMenu(id, items)-- update one menu without recreating its status itemruntime.updateStatusItemPresentation(id, presentation)-- update one live title and visual presentationruntime.removeStatusItem(id)-- remove only the identified itemruntime.createTray(options)-- create or replace the tray iconruntime.updateTrayMenu(items)-- update menu items without recreating the trayruntime.updateTrayTitle(title)-- update only the live tray titleruntime.updateTrayPresentation(presentation)-- update the live title and visual presentationruntime.removeTray()-- remove the tray icon
The singular *Tray methods are compatibility wrappers for reserved status-item id 1. The lower-level PlatformServices surface exposes the same keyed methods for platform adapters. Runtime supports at most eight simultaneous status items and validates every menu independently: non-separator rows need a label, command-backed rows need a unique non-zero row id, and each menu is capped at 32 rows.
TypeScript: model-derived status items
In a TypeScript app, export statusItem(model) from src/core.ts. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, number style, or rows patches only that channel and never recreates the native status item.
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
import { type StatusItemState } from "@native-sdk/core/events";
export function statusItem(model: Model): StatusItemState {
return {
iconPath: asciiBytes("assets/menu-bar.svg"),
tooltip: utf8Bytes("Player status"),
activationCommand: asciiBytes("app.refresh"),
alternateActivationCommand: asciiBytes("player.toggle"),
openCommand: asciiBytes("app.refresh"),
presentation: {
title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"),
width: model.playing ? 72 : 48,
tone: model.failed ? "critical" : "normal",
iconOpacity: model.stale ? 0.5 : 1,
monospaced: true,
},
items: [
{ id: 10, label: model.today, command: asciiBytes(""), separator: false, enabled: false, detail: model.quota, role: "hero", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 3, label: utf8Bytes("Settings…"), command: asciiBytes("app.settings"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(","), modifiers: { primary: false, command: true, control: false, option: false, shift: false } },
],
};
}iconPath, tooltip, activationCommand, alternateActivationCommand, and openCommand update live alongside presentation and rows. A normal click emits activationCommand and opens the menu; an Option-click emits alternateActivationCommand without opening it. Every menu open emits openCommand, which is useful for an on-demand refresh while the background cadence stays slow. These lifecycle hooks dispatch with source = .tray; empty commands disable them.
For multiple independent items, export statusItems(model): readonly StatusItemDescriptor[] instead. Each descriptor has the same shell, presentation, and row fields plus a stable non-zero id and live visible flag. Presence creates, absence removes, and changed fields patch only that identifier; menus update without replacing their NSStatusItem. Export either statusItem or statusItems, not both. This is the Vercel-shaped split: one spend indicator can appear or disappear while a separate control-menu item persists.
Rows use the exact StatusItemMenuItem record. role is command, info, header, hero, agent, or context; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. detail carries secondary readout content, and key plus the five explicit modifiers fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with commandMsg(name): Msg | null; no Zig status_item_fn glue is needed.
Use utf8Bytes for titles, labels, tooltips, and details; it preserves characters such as …, ·, and emoji as UTF-8. Use asciiBytes for guaranteed-ASCII command names, keys, paths, and empty byte fields. Passing non-ASCII literal/template text to asciiBytes is an NS1064 build error.
Handling tray actions
When a user clicks a tray menu item, the runtime dispatches a CommandEvent with source .tray, the native status_item_id, and that menu's tray_item_id. Row ids only need to be unique within their own menu. Prefer command-backed items when the item represents a known app action:
try runtime.createTray(.{
.tooltip = "native-sdk",
.items = &.{
.{ .id = 1, .label = "Refresh", .command = "app.refresh" },
.{ .separator = true },
.{ .id = 2, .label = "Quit", .command = "app.quit" },
},
});Use your event_fn to handle the commands — every declared name gets a real consequence, "app.quit" included:
fn event(context: *anyopaque, runtime: *Runtime, ev: Event) anyerror!void {
switch (ev) {
.command => |cmd| {
if (std.mem.eql(u8, cmd.name, "app.refresh")) {
// Refresh the model, re-render.
} else if (std.mem.eql(u8, cmd.name, "app.quit")) {
// The REAL graceful terminate: the host emits the same
// shutdown event a last-window close does, so the stop
// hook runs exactly once.
try runtime.quitApp();
}
},
else => {},
}
}Menu-bar extras in UiApp
Canvas-first apps declare the status item once, with UiApp.Options.status_item (installed on the installing frame). Selecting a menu item dispatches its command through the ordinary on_command mapping with source .tray:
app.* = PreviewApp.init(allocator, .{}, .{
// ...
.on_command = command,
.status_item = .{
.title = "ZN",
.tooltip = "Native SDK Canvas Preview",
.items = &.{
.{ .id = 1, .label = "Show Docs", .command = "app.docs" },
.{ .separator = true },
.{ .id = 2, .label = "Reload Preview", .command = "app.reload" },
},
},
});macOS (NSStatusItem) is the proven host; platforms without a status-bar service log a warning and continue. See examples/canvas-preview for the live composition.
Model-driven title and menu in Zig-core apps
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add UiApp.Options.status_item_fn. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: shell, presentation, and menu changes patch independently without flicker or native-item recreation. The static status_item provides defaults for icon, tooltip, activation, alternate-activation, and open commands; the callback may update those fields live too.
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
const title = std.fmt.bufPrint(&scratch.title_buffer, "ZN {d}", .{model.open_count}) catch "ZN";
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
scratch.items[1] = .{ .separator = true };
scratch.items[2] = .{ .id = 10, .label = model.latest_title, .command = "issue.select.latest" };
return .{
.presentation = .{ .title = title, .width = 62, .monospaced = true },
.items = scratch.items[0..3],
};
}
// options: .status_item_fn = statusItem,Selections dispatch each item's command through on_command with source .tray, the same shape as window menus. Platforms without a tray-title seam keep the menu updates and log the missing title support once.
For multiple Zig-core items, use UiApp.Options.status_items_fn, returning up to eight App.StatusItemDescriptor values from App.StatusItemsScratch. Descriptor presence creates/removes by id; visible hides without removing; shell, presentation, and menu hashes reconcile independently. It is mutually exclusive with the singular status_item / status_item_fn pair.
The menu-bar app lifecycle
The default TypeScript + Native markup recipe combines one app policy, two window policies, and two commands:
- Declare the
"tray"capability and set top-leveldock_visible = false. macOS selects the Accessory activation policy before creating the startup window, so no Dock tile or cmd+Tab entry flashes. The tray requirement is validated because it becomes the app's route back to hidden windows. - Set
initially_hidden = trueon the startup window when the app should launch behind the status item.dock_visible = falsealone removes desktop presence; it does not suppress the window. - Set
close_policy = "hide", so the red close button hides the window instead of quitting. The default"quit"keeps classic windowed-app behavior; see Windows. - Map tray rows to
Cmd.showWindow("main")andCmd.quitApp(). Show unhides, orders front, and activates; Quit follows the real graceful shutdown path.
.capabilities = .{ "native_views", "gpu_surfaces", "tray" },
.dock_visible = false,
.shell = .{
.windows = .{
.{
.label = "main",
.initially_hidden = true,
.close_policy = "hide",
// views...
},
},
},import { Cmd } from "@native-sdk/core";
export function commandMsg(name: string): Msg | null {
if (name === "app.open") return { kind: "open_player" };
if (name === "app.quit") return { kind: "quit" };
return null;
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "open_player":
return [model, Cmd.showWindow("main")];
case "quit":
return [model, Cmd.quitApp()];
// other arms...
}
}Cmd.setDockPresence(true) can later promote the same running Accessory app to Regular; passing false demotes it again. examples/menu-bar is the complete zero-Zig loop with statusItem(model) supplying the live title and rows.
Linux is the honest exception: the toolkit has no status item there yet, so nothing could bring a hidden window back — close_policy = "hide" is refused at build/create time with a teaching, and the platform-support matrix states it plainly.
A future .event close-policy tier (the model receives the close request and decides — unsaved-changes prompts) is deliberately left room for but is not implemented yet; model-declared secondary windows already have that shape today through WindowDescriptor.on_close.