Skip to content

Desktop features ​

Files, dialogs, the clipboard, menus, the tray, notifications, and more come from the official plugins plus the capability-checked services in desktop.

  • A plugin (plugin/official) declares commands, the permissions they need, and events. It contains no behaviour.
  • A service (desktop.ClipboardService, …) checks the caller's grant again and calls the native host.
  • You bind each command to its service. That is where you decide what the page can actually do.

See the plugin reference for every command and permission.

Turn them on ​

go
a, err := app.New(app.Options{ /* ... */ })
if err != nil {
    return err
}
// Register every official plugin and bind its commands to the native host.
if err := a.UseOfficialPlugins(ctx); err != nil {
    return err
}

Or take only what you need: a.UseOfficialPlugins(ctx, official.Clipboard(), official.Dialog()). Call it after app.New and before Run.

This grants nothing. Every command still needs a grant for the calling window, and each service checks that grant again before it touches the host, including path scopes for fs.* and path.open.

It also forwards native activations: menu, tray, and shortcut activations become menu.action, tray.action, and shortcut.action events ({"id": ...}), and file drops become dragdrop.drop ({"window", "paths"}). Subscribe the windows that should receive them.

Every official command is a typed command. Its input is decoded strictly into a type in plugin/official (official.DialogOptions, official.WindowSizeInput, …): an unknown field or a wrong type fails with a validation error before the host is touched, and the generated client types each call. See the plugin reference for every input and output.

Picking files ​

dialog.open returns an array of paths, or null when the user cancels. It takes an optional object: title, defaultPath, filters ([{"name": "Images", "extensions": ["png", "jpg"]}]), and multiple:

js
const paths = await window.vitra.invoke("dialog.open", {
  title: "Add attachments",
  multiple: true,
  filters: [{ name: "Documents", extensions: ["pdf", "md"] }],
});

Without multiple (or with false) the dialog selects one file, as before; a multiple that is not a boolean is rejected. With multiple: true the Linux, macOS, and Windows hosts let the user select several files and return all of them. A host that cannot do that returns platform.ErrUnsupported; it never quietly returns a single file. Custom hosts opt in by implementing platform.MultiFileOpener.

Picking a file grants nothing. Reading it with fs.read still needs an fs.read grant whose path scope covers it.

Binding by hand ​

UseOfficialPlugins is a convenience over the pieces in desktop. To change how a command behaves, register the plugin yourself and bind its commands to a service with vitra.Bind, which gives a plugin's command a typed handler:

go
if err := rt.RegisterPlugin(ctx, official.Clipboard()); err != nil {
    return err
}
clips := &desktop.ClipboardService{
    Gateway: rt, Host: host,
    OnRead:  func(context.Context) (string, error) { return host.ClipboardGet() },
    OnWrite: func(_ context.Context, s string) error { return host.ClipboardSet(s) },
}
err := vitra.Bind(rt, "clipboard.read",
    func(ctx context.Context, inv domain.Invocation, _ struct{}) (string, error) {
        return clips.Read(ctx, inv.Caller)
    })

Pass inv.Caller to the service, never an identity from the input. For input, reuse the types in plugin/official so your binding decodes and validates exactly as the built-in one does. Use vitra.Void as the output of a command that returns nothing.

Registering a plugin claims its permissions, so no other plugin can declare them.

Then grant the permissions ​

go
grant, _ := domain.NewCapabilityGrant("desktop", "clipboard and dialogs",
    []domain.WindowID{"main"}, []domain.Origin{domain.OriginPackagedLocal},
    []domain.PermissionSpec{
        {Name: desktop.PermClipboardWrite},
        {Name: desktop.PermDialogOpen},
    })

Grant the least you can. Reading the clipboard, opening URLs, and scoped file access are each worth a second thought.

Things the host does for the app ​

Menus, the tray, global shortcuts, and single-instance handoff are often set up by Go, not by the page. Call the service with a caller that represents the app and has its own grant:

go
hostCaller, _ := domain.NewCaller("main", domain.OriginPackagedLocal)
menus.SetMenu(ctx, hostCaller, []desktop.MenuItem{
    {Menu: "File", ID: "app.quit", Label: "Quit", Shortcut: "Ctrl+Q"},
})

Command handlers never use this caller. They act as the window that called them.

A menu bar or tray app usually drives the tray from Go, for example from a poller that refreshes the status. App.SetTray needs no grant (it is your code, not the page's), and App.OnAction hands item clicks to Go:

go
//go:embed icons/tray.png
var trayIcon []byte

err := application.SetTray(app.TraySpec{
    Title: "42%", Tooltip: "Usage this week", Icon: trayIcon, Template: true,
    Items: []platform.MenuItem{
        {ID: "refresh", Label: "Refresh"},
        {Separator: true},
        {ID: "quit", Label: "Quit"},
    },
})
application.OnAction(func(id string) {
    if id == "quit" {
        application.Quit()
    }
})

Set ClickActivates to handle the left click yourself, for example to open a panel: the click goes to App.OnTrayClick (and to the page as tray.click) with the icon's screen rectangle when the host knows it, and the menu moves to the right click. App.TrayAnchor returns the same rectangle at any time. The rectangle is in the host's own window coordinates, so only pass it back to the same host. Some Wayland panels report no position at all.

For a panel that drops down from the tray, as menu bar apps such as CodexBar have, make the primary window a panel and name it in the tray. A panel is frameless, stays above other windows, has no taskbar entry, opens hidden, and hides when it loses focus. A left click on the icon shows it under the icon (above it when the taskbar is at the bottom) and the next click hides it. App.ShowTrayPanel and App.HideTrayPanel do the same from Go, for example from a global shortcut:

go
application, err := app.New(app.Options{
    AppID: "com.example.usage", Assets: assets, Host: host,
    Presentation: app.PresentationAccessory,
    Window: app.WindowOptions{ID: "panel", Path: "/panel.html", Width: 360, Height: 420, Kind: app.WindowKindPanel},
})
err = application.SetTray(app.TraySpec{Title: "42%", Icon: trayIcon, Template: true, Panel: "panel"})

The panel is an ordinary window to the security model: it can call nothing until a grant names it, its calls are attributed to it, and navigating it moves no authority. Wayland compositors decide where windows go, so there the panel is usually centered.

Menu bar apps puts these pieces together step by step.

A menu bar app usually has no Dock icon and no main window. Set Presentation: app.PresentationAccessory and leave Window.ID empty; call SetTray before Run, since an app with neither a window nor a tray could not be seen or quit. Closing an accessory app's last window does not quit it. On macOS it runs without a Dock icon or app menu; on Linux and Windows its windows get no taskbar entry. Package it with vitra package --accessory so macOS shows no Dock icon from launch either.

go
application, err := app.New(app.Options{
    AppID: "com.example.usage", Assets: assets, Host: host,
    Presentation: app.PresentationAccessory, // no Window: tray only
})

Title is text next to the icon: in the macOS menu bar and as the Linux StatusNotifierItem label. Windows and the Linux GtkStatusIcon fallback show no text there, so they add the title to the tooltip; check Features()[platform.FeatureTrayTitle] to tell. Template marks a macOS template image (black with transparency), which the system tints to match the menu bar.

Platform support ​

Every host reports its features (host.Features()). Anything unavailable returns platform.ErrUnsupported with a reason instead of silently doing nothing. Notable cases:

  • Linux: global shortcuts work on X11 (XGrabKey). On Wayland they go through the org.freedesktop.portal.GlobalShortcuts portal (GNOME 48+, KDE Plasma 6, other desktops whose xdg-desktop-portal backend implements it). Features() reports the shortcut feature available only while that portal is running; without it shortcut.register returns ErrUnsupported, so fall back to in-window menu shortcuts (MenuItem.Shortcut). Through the portal:
    • The accelerator is the preferred key. The desktop may show a consent dialog, and the user can approve, change the key, or refuse. A refusal or cancel is returned as an error from shortcut.register.
    • Each action id is one portal shortcut, so registering a second accelerator for the same action replaces the first.
    • The portal cannot unbind a single shortcut. shortcut.unregister rebinds the remaining shortcuts on a new portal session, which the desktop may confirm with the user again.
    • Desktops attribute shortcuts to an app id. An unsandboxed app with a reverse-DNS program name (host.SetProgramName("com.example.Notes"), matching its .desktop file) registers it with the portal, so the user's choices are remembered between runs. Flatpak apps get their id from the sandbox.
  • macOS: notifications need an app bundle with a bundle identifier. An unbundled binary (go run, vitra dev) gets ErrUnsupported; package the app with vitra package --format app-dir.
  • Windows: requires the WebView2 runtime.
  • Linux tray: when a org.kde.StatusNotifierWatcher is on the session bus, the tray is a StatusNotifierItem with a com.canonical.dbusmenu menu, spoken directly over D-Bus with no extra library. KDE Plasma, GNOME with the AppIndicator extension (on by default in Ubuntu, Fedora needs it installed), XFCE, Cinnamon, MATE, LXQt, Budgie, and panels such as waybar show it, on X11 and Wayland. Without a watcher, Vitra falls back to the legacy XEmbed GtkStatusIcon, which only X11 panels with a system tray show; stock GNOME without the extension shows no tray at all. Features() says which protocol is in use. Either way, menu clicks emit tray.action with the item's ID and a left click on the icon emits tray.action with tray.activate. Menus are flat lists and labels are shown literally. The icon is the app's PNG when it sets one, otherwise the generic application-x-executable. The title is the item's XAyatanaLabel, which GNOME's AppIndicator extension and Ubuntu show next to the icon; KDE Plasma shows it on hover.

A custom host implements only the capabilities it supports; a command whose capability is missing fails with ErrUnsupported. See Host interfaces.

Apache-2.0 licensed.