Golang library to create menubar apps- programs that live only in OSX's NSStatusBar
Docs, guides, and a live app showcase: menuet.app
Under active development. API still changing rapidly.
menuet requires OS X.
go get github.com/caseymrm/menuet/v2
- menuet.app — guides, API tour, and a live showcase of apps built with menuet (each rendered from a real
menuet-demo.jsonsnapshot — seemake web-previewbelow) - pkg.go.dev/github.com/caseymrm/menuet/v2 — generated API reference
Set Application.Clicked to intercept left clicks on the menubar icon
instead of opening the menu. The menu still opens on right click (or
Ctrl-left-click). Useful for toggle-style apps — mute audio, pause a
timer, etc. — where the menu is the secondary UI:
menuet.App().Clicked = func() {
// toggle whatever state your app exposes
}Leave Clicked as nil (the default) for the standard behavior where
any click opens the menu. Safe to set or clear at runtime; the next
click reflects the current value.
go run is fine for early development, but several menuet features only work
when the binary is launched from inside a proper macOS .app bundle. These
requirements are enforced by macOS, not by menuet:
- Notifications require a bundle.
UNUserNotificationCenterwill silently no-op for a loose executable. The app also needs to be code-signed — ad-hoc signing is not enough; you need a Developer ID signature (or full notarization for distribution). - Start at Login prefers the macOS 13+ Service Management framework
(
SMAppService) when available, which puts the app under System Settings → Login Items so the user can revoke it from the standard place. For older macOS or unsigned dev builds the older LaunchAgent plist path is used as a fallback. Either backend wants the app to be bundled. - Auto-update moves a new
.appbundle on top of the running one, so it obviously needs a bundle to update.
The shared menuet.mk Makefile assembles a minimal bundle for you. From your
app directory, create a Makefile like:
APP=My App
IDENTIFIER=com.example.myapp
include $(GOPATH)/src/github.com/caseymrm/menuet/menuet.mkThen make run builds the binary into My App.app/Contents/MacOS/myapp,
generates My App.app/Contents/Info.plist with your CFBundleIdentifier,
and launches it. The cmd/catalog example uses this pattern.
To sign the bundle for notifications and distribution, set IDENTITY to a
Developer ID Application certificate from your Keychain and run make sign.
Browse the live showcase at menuet.app/apps — each app's menu is rendered from its committed snapshot. The list below is a subset:
- Why Awake? - shows why your Mac can't sleep, and lets you force it awake
- Not a Fan - shows your Mac's temperature and fan speed, notifies you when your CPU is being throttled due to excessive heat
- Traytter - minimalist Twitter client for following a few users
- Hacker News Menuet - easily browse latest Hacker News posts
package main
import (
"time"
"github.com/caseymrm/menuet/v2"
)
func helloClock() {
for {
menuet.App().SetMenuState(&menuet.MenuState{
Title: "Hello World " + time.Now().Format(":05"),
})
time.Sleep(time.Second)
}
}
func main() {
go helloClock()
menuet.App().RunApplication()
}MenuItem is an interface; the concrete types are Regular (a normal row)
and Separator (a horizontal divider). Construct a menu by returning a
[]menuet.MenuItem containing whichever concrete types you need:
menuet.App().Children = func() []menuet.MenuItem {
return []menuet.MenuItem{
menuet.Regular{Text: "Status: Active"},
menuet.Separator{},
menuet.Regular{Text: "Refresh", Clicked: refresh},
menuet.Regular{Text: "Submenu", Children: subItems},
}
}Regular carries the familiar fields — Text, Image, FontSize,
FontWeight, State, Clicked, Children. Setting Clicked makes it
clickable; setting Children makes it a submenu.
For apps where the primary action is a toggle (mute audio, pause a timer, hide notifications…), macOS menus dismiss the moment the user clicks an item — there's no public API to "click without closing." Two patterns work around it:
Left click toggles, right click opens the menu. Set
Application.Clicked to a callback; left clicks fire the callback
without opening the menu, right clicks (and Ctrl-left-clicks) still
open the menu for secondary actions:
menuet.App().Clicked = func() { toggleMuted() }Stateful menu items with checkmarks. For toggles you do want inside
the menu, set MenuItem.State = true to show a checkmark, and update
your app state from the Clicked callback. The menu will dismiss on
click as usual (OS standard); on the next open, return the items with
the new State:
menuet.Regular{
Text: "Notifications enabled",
State: prefs.NotificationsEnabled,
Clicked: func() { prefs.NotificationsEnabled = !prefs.NotificationsEnabled },
}The catalog app is useful for trying many of the possible combinations of features.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"time"
"github.com/caseymrm/menuet/v2"
)
func temperature(woeid string) (temp, unit, text string) {
url := "https://query.yahooapis.com/v1/public/yql?format=json&q=select%20item.condition%20from%20weather.forecast%20where%20woeid%20%3D%20" + woeid
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
var response struct {
Query struct {
Results struct {
Channel struct {
Item struct {
Condition struct {
Temp string `json:"temp"`
Text string `json:"text"`
} `json:"condition"`
} `json:"item"`
Units struct {
Temperature string `json:"temperature"`
} `json:"units"`
} `json:"channel"`
} `json:"results"`
} `json:"query"`
}
dec := json.NewDecoder(resp.Body)
err = dec.Decode(&response)
if err != nil {
log.Fatal(err)
}
return response.Query.Results.Channel.Item.Condition.Temp, response.Query.Results.Channel.Units.Temperature, response.Query.Results.Channel.Item.Condition.Text
}
func location(query string) (string, string) {
url := "https://query.yahooapis.com/v1/public/yql?format=json&q=select%20woeid,name%20from%20geo.places%20where%20text%3D%22" + url.QueryEscape(query) + "%22"
resp, err := http.Get(url)
if err != nil {
log.Printf("Get: %v", err)
menuet.App().Alert(menuet.Alert{
MessageText: "Could not get the weather",
InformativeText: err.Error(),
})
return "", ""
}
var response struct {
Query struct {
Results struct {
Place struct {
Name string `json:"name"`
WoeID string `json:"woeid"`
} `json:"place"`
} `json:"results"`
} `json:"query"`
}
dec := json.NewDecoder(resp.Body)
err = dec.Decode(&response)
if err != nil {
log.Printf("Decode: %v", err)
menuet.App().Alert(menuet.Alert{
MessageText: "Could not search for location",
InformativeText: err.Error(),
})
return "", ""
}
return response.Query.Results.Place.Name, response.Query.Results.Place.WoeID
}
func temperatureString(woeid string) string {
temp, unit, text := temperature(woeid)
return fmt.Sprintf("%s°%s and %s", temp, unit, text)
}
func setWeather() {
menuet.App().SetMenuState(&menuet.MenuState{
Title: temperatureString(menuet.Defaults().String("loc")),
})
}
var woeids = map[int]string{
2442047: "Los Angeles",
2487956: "San Francisco",
2459115: "New York",
}
func menuPreview(woeid string) func() []menuet.MenuItem {
return func() []menuet.MenuItem {
return []menuet.MenuItem{
menuet.Regular{
Text: temperatureString(woeid),
Clicked: func() {
setLocation(woeid)
},
},
}
}
}
func menuItems() []menuet.MenuItem {
items := []menuet.MenuItem{}
currentWoeid := menuet.Defaults().String("loc")
currentNumber, err := strconv.Atoi(currentWoeid)
if err != nil {
log.Printf("Atoi: %v", err)
}
found := false
for woeid, name := range woeids {
woeStr := strconv.Itoa(woeid)
items = append(items, menuet.Regular{
Text: name,
Clicked: func() {
setLocation(woeStr)
},
State: woeStr == menuet.Defaults().String("loc"),
Children: menuPreview(woeStr),
})
if woeid == currentNumber {
found = true
}
}
if !found {
items = append(items, menuet.Regular{
Text: menuet.Defaults().String("name"),
Clicked: func() {
setLocation(currentWoeid)
},
Children: menuPreview(currentWoeid),
State: true,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].(menuet.Regular).Text < items[j].(menuet.Regular).Text
})
items = append(items, menuet.Regular{
Text: "Other...",
Clicked: func() {
response := menuet.App().Alert(menuet.Alert{
MessageText: "Where would you like to display the weather for?",
Inputs: []menuet.AlertInput{{Placeholder: "Location"}},
Buttons: []string{"Search", "Cancel"},
})
if response.Button == 0 && len(response.Inputs) == 1 && response.Inputs[0] != "" {
newName, newWoeid := location(response.Inputs[0])
if newWoeid != "" && newName != "" {
menuet.Defaults().SetString("loc", newWoeid)
menuet.Defaults().SetString("name", newName)
menuet.App().Notification(menuet.Notification{
Title: fmt.Sprintf("Showing weather for %s", newName),
Subtitle: temperatureString(newWoeid),
})
setWeather()
}
}
},
})
return items
}
func hourlyWeather() {
for {
setWeather()
time.Sleep(time.Hour)
}
}
func setLocation(woeid string) {
menuet.Defaults().SetString("loc", woeid)
setWeather()
}
func main() {
// Load the location from last time
woeid := menuet.Defaults().String("loc")
if woeid == "" {
menuet.Defaults().SetString("loc", "2442047")
}
// Start the hourly check, and set the first value
go hourlyWeather()
// Configure the application
menuet.App().Label = "com.github.caseymrm.menuet.weather"
// Hook up the on-click to populate the menu
menuet.App().Children = menuItems
// Run the app (does not return)
menuet.App().RunApplication()
}Menuet is licensed under the MIT license, so you are welcome to make closed source menubar apps with it as long as you preserve the copyright. For details see the LICENSE file.