DOCUMENTATION

Build a macOS menu‑bar app in Go.

menuet is a small library for programs that live in the macOS menu bar — the NSStatusBar at the top‑right of the screen. You write Go; menuet handles the AppKit bridge.

Introduction

A menuet app is a single Go binary with no main window. You set what shows in the bar, return a slice of menu items for the dropdown, and call one blocking run loop. Everything else — styling, notifications, shortcuts, updates — is opt‑in.

menuet requires macOS. The current module path is github.com/caseymrm/menuet/v2, and the library is MIT licensed, so closed‑source apps are fine as long as you keep the copyright.

Installation

Add the module to your project:

$ go get github.com/caseymrm/menuet/v2

go run is fine while you iterate, but a few features — notifications, start‑at‑login, and auto‑update — only work from inside a proper .app bundle. See Bundling & distribution.

Your first app

Three calls make a working app: App() for the singleton, SetMenuState for the bar title, and RunApplication to start the loop (it never returns).

package main

import (
    "time"

    "github.com/caseymrm/menuet/v2"
)

func clock() {
    for {
        menuet.App().SetMenuState(&menuet.MenuState{
            Title: time.Now().Format("3:04:05"),
        })
        time.Sleep(time.Second)
    }
}

func main() {
    go clock()
    menuet.App().RunApplication()
}

Call SetMenuState from anywhere, any time — a background goroutine here updates the clock once a second. menuet debounces rapid updates for you.

The status item

MenuState controls what appears in the bar. Set a plain Title, an Image (a file in your Resources directory or a URL, sized to 22pt tall), or Runs for per‑segment styled text — covered next.

menuet.App().SetMenuState(&menuet.MenuState{
    Title: "LAL 102 · GSW 98",
    Image: "basketball", // 22pt, in Resources/
})

Styled titles & text

A TextRun is one styled segment. Concatenate runs in MenuState.Runs (for the bar) or Regular.Runs (for a row) to mix weight, color, monospaced digits, and pill badges. Each style field’s zero value means “inherit,” so a run can change just one attribute.

menuet.MenuState{
    Runs: []menuet.TextRun{
        {Text: "LAL ", FontWeight: menuet.WeightSemibold},
        {Text: "102", Monospaced: true},
        {Text: "LIVE", Badge: true, Color: menuet.SystemRed},
    },
}

Colors are either fixed RGBA or semantic — names like LabelSecondary or SystemRed that resolve to dynamic AppKit colors and adapt to light/dark automatically. Runs also support Underline, Strikethrough, Background, and Shadow.

Above: Sportsbar’s actual menu, rendered live from a committed snapshot.

Toggle-style apps

For apps whose primary action is a toggle — mute, pause, force‑awake — set App().Clicked. A left click fires your callback without opening the menu; a right click (or Ctrl‑click) still opens it for secondary actions. Leave it nil for the standard “any click opens the menu” behavior.

menuet.App().Clicked = func() {
    toggleMuted() // left click; menu opens on right click
}

Global shortcuts

Give a Regular a Shortcut and it does two things: shows the key combo in the menu (⌘R), and registers a system‑wide hotkey that fires Clicked even when your app isn’t frontmost. Combine modifiers with ModCmd | ModShift; key codes are constants like KeyR.

menuet.Regular{
    Text:     "Refresh scores",
    Shortcut: &menuet.Shortcut{KeyCode: menuet.KeyR, Modifiers: menuet.ModCmd},
    Clicked:  refresh,
}

Notifications

Show a native banner with App().Notification. Add an action button, an inline reply, or a stable Identifier (re‑using one updates the existing notification rather than stacking). Respond to button taps via App().NotificationResponder. Notifications require a signed app bundle.

menuet.App().Notification(menuet.Notification{
    Title:    "Lakers score!",
    Subtitle: "LAL 104 · GSW 98",
    Identifier: "game-LALGSW", // updates in place
})

Alerts

App().Alert shows a modal dialog and blocks until the user responds, returning which button they pressed and any input values. Add text or password Inputs to collect a value inline.

resp := menuet.App().Alert(menuet.Alert{
    MessageText: "Add a team",
    Inputs:      []menuet.AlertInput{{Placeholder: "Team name"}},
    Buttons:     []string{"Add", "Cancel"},
})
if resp.Button == 0 {
    addTeam(resp.Inputs[0])
}

Persisting state

Defaults() wraps NSUserDefaults for small bits of persistent state — strings, integers, booleans, or any JSON‑able value via Marshal / Unmarshal.

menuet.Defaults().SetBoolean("notify", true)
on := menuet.Defaults().Boolean("notify")

menuet.Defaults().Marshal("teams", myTeams) // any JSON value

Auto-update

Set AutoUpdate.Version and AutoUpdate.Repo and menuet checks GitHub releases once a day. If a newer release exists it offers an update, downloads the .zip asset, swaps the bundle in place, and restarts. Set AllowPrerelease to opt into prereleases.

app := menuet.App()
app.AutoUpdate.Version = "1.2"
app.AutoUpdate.Repo = "caseymrm/sportsbar"

Start at login

menuet adds a “Start at Login” item to the menu automatically once you set an App().Label. On macOS 13+ it registers via the Service Management framework, so users can revoke it from System Settings → Login Items; older systems fall back to a LaunchAgent. Hide the item with HideStartup(), or relabel it (and Quit) with StartAtLoginLabel / QuitLabel.

menuet.App().Label = "com.example.sportsbar"

Bundling & distribution

Notifications, start‑at‑login, and auto‑update all need a real .app bundle — these are macOS requirements, not menuet’s. The shared menuet.mk Makefile assembles a minimal one. From your app directory:

# Makefile
APP=Sportsbar
IDENTIFIER=com.example.sportsbar
include $(GOPATH)/src/github.com/caseymrm/menuet/menuet.mk

make run builds and launches the bundle. To sign for notifications and distribution, set IDENTITY to a Developer ID certificate and run make sign — ad‑hoc signing isn’t enough.

Web preview

menuet ships with a snapshot mode that captures your menu as JSON so it can be rendered as HTML — for screenshots, README mockups, or this site’s apps directory. Run:

$ make web-preview

Set MENUET_SNAPSHOT_PATH=file.json and your app writes a snapshot at startup and exits cleanly instead of entering the AppKit run loop. MENUET_SNAPSHOT_DELAY (default 2s) gives data-fetching goroutines time to populate state first. The shared menuet.mk has a web-preview target already wired up.

The pure-Go github.com/caseymrm/menuet/v2/html package renders the snapshot to a safe HTML fragment — no code execution, every value validated against a whitelist. It’s what powers every menu mockup you see on this site.

That’s the whole library.

For the complete type‑by‑type reference, see the GoDoc. Or see it all working in a real app.