statusitem

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-macos/statusitem

ci Go Reference License

A macOS menu-bar status item — the thing everyone calls a "tray icon" — with a menu, from pure Go, CGO_ENABLED=0. No cgo, no osascript, no Objective-C source file anywhere in the build: it reaches AppKit through go-macos/objc, which reaches it through purego.

item, err := statusitem.New("⌘", []statusitem.MenuItem{
        {Title: "Status: idle"},                                  // no Do → a disabled row
        {},                                                       // no Title → a separator
        {Title: "Preferences…", Key: ",", Do: openPreferences},   // ⌘,
        {Title: "Quit", Key: "q", Do: quit},                      // ⌘Q
})
if err != nil {
        return err
}
defer item.Close()

That is the whole API:

New(title string, items []MenuItem) (*Item, error) put an item in the menu bar. title is text or an emoji.
(*Item) SetTitle(string) error replace what is drawn.
(*Item) SetMenu([]MenuItem) error replace the whole menu.
(*Item) Close() error remove it. A second Close reports ErrClosed.
MenuItem{Title, Key string; Do func()} one row. IsSeparator() is the rule the package applies.

The setters return errors, and that is on purpose

A SetTitle/SetMenu that returned nothing would have to be lenient, and being lenient here means silently discarding something the caller wrote:

  • An empty title. AppKit accepts one and draws a zero-width item — present in the menu bar, impossible to see and impossible to click. There is no image parameter here to make an empty title mean something, so it is ErrEmptyTitle.
  • A row with an empty Title that carries a Do. An empty title is how this package spells separator, and a separator cannot be chosen, so the Do could never run: ErrSeparatorNotEmpty.
  • A key equivalent that is not exactly one character. -[NSMenuItem setKeyEquivalent:] takes any string, draws it in the row, and then never matches it against a keystroke. A shortcut that is drawn and dead is worse than one that is refused: ErrKeyNotOneRune.
  • A NUL byte. The Objective-C bridge is +stringWithUTF8String:, which terminates at the first NUL, so the string would be truncated with no error at all: ErrHasNUL.

The row number is in the message, because "a separator cannot carry a Do" for a fifteen-row menu sends you through all fifteen.

It needs a host application, and it says which parts need what

This package does not create an application. It expects a process that already has an NSApplication whose run loop is on the process main OS thread — in this fleet that is go-widgets/window; in a bare binary it is objc.RunApp. The distinction that matters:

without a running run loop
+[NSStatusBar systemStatusBar], -statusItemWithLength:, -setTitle:, -setMenu: work. An item can be built during start-up, and New is safe to call there.
drawing, and choosing a row never happen.

So a status item in a process whose main thread never runs a loop is an object with no window — and from Go it is indistinguishable from one that works. That is the failure this package's live test exists to rule out, and it does so by reading -[NSStatusBarButton window] back out of AppKit.

If there is no NSApplication at all, New creates the shared one as a side effect, because -systemStatusBar is meaningless before AppKit has an application object. It does not call -finishLaunching and does not touch the activation policy: those belong to whoever owns the process, and changing them under a host would move its Dock tile or replace its main menu.

The main thread, and the hang that is not a hang

AppKit is main-thread-only, and violating that does not fail politely: in this fleet's own tray code it was an Objective-C exception and a SIGABRT, intermittently, depending on which OS thread the Go scheduler happened to have the goroutine on.

So every AppKit call here is marshalled onto the process main thread, and every exported function is safe to call from any goroutine. -[NSThread isMainThread] decides how:

  • On the main thread, the work runs inline. It has to: performSelectorOnMainThread:…waitUntilDone:NO would queue it for the next turn of a loop, so a call made during start-up would never happen at all.
  • Off the main thread, the work is queued and waited for with a timeout. waitUntilDone:YES from a goroutine in a process whose main thread is not running a loop blocks that goroutine forever — no error, no deadlock detection (the runtime sees a live thread), and no frame of this package anywhere in the stack. After MainHopTimeout (5s) the call reports ErrNoMainLoop instead.

Handlers do not run on the main thread. MenuItem.Do is called on a fresh goroutine. A handler that blocks — a network fetch, a lock, a channel nobody is reading — would otherwise freeze the menu bar of the whole session, not merely this application. The cost is that two rapid choices can run concurrently and that a handler touching AppKit must marshal itself back; that is the cheaper of the two mistakes, and the live suite asserts it (onMainThread() is false inside a handler, and true on the main thread, so the assertion is not vacuous).

What was measured, not assumed

On macOS 26.6.2 (25G83), arm64, by the live suite in this repository:

An unbundled binary DOES get a real menu-bar item. This is the question worth settling, because the failure would be silent. The test binary has no .app and no Info.plist[[NSBundle mainBundle] bundleIdentifier] reads "" — and its status item still comes back with a live -[NSStatusBarButton window] (0x1517096f0 on one run), -[NSStatusItem isVisible] true, in a status bar reporting a thickness of 22 pt. No bundle, no Info.plist, no LSUIElement key was needed for any of it. objc.RunApp(1) in cmd/statusitemdemo shows the same item in the menu bar of a real session.

A separator arrives with tag 0, and 0 is a valid handler index. Found by reading the tag back out of a built menu rather than by trusting +[NSMenuItem separatorItem]. Left alone it would put the first chooseable row's function one stray action-dispatch away from a divider, so buildMenu overwrites it — after checking, in a test, that +separatorItem really does hand back a distinct object each time, which is what makes writing to it safe.

An NSStatusItem owns its button, so the button dies with it. The first version of the removal test kept the button pointer across Close and sent it -window afterwards. That is a use-after-free: it passed two runs in three and failed the third with a SIGSEGV inside objc_msgSend, at a program counter with nothing of this package in the stack — exactly the flake that gets blamed on the runner. The test retains what it reads now.

The button's -window POINTER is not an observable of removal, and believing it was cost a red CI lane. After Close it read 0x0 on this machine and looked like a perfect assertion. On a GitHub macos-latest runner it read 0xc60d28780 both before and after, and the darwin lane went red for a package that was working correctly. It only went nil here because releasing the item deallocated the window — a dealloc, not a removal, and deallocs are not synchronous. What -removeStatusItem: does synchronously, and identically on both machines, is order the window out: -[NSWindow isVisible] goes from true to false. That is what the test measures now.

Running the tests

# Portable + live. The live tests really place items in your menu bar and remove
# them again; with no window server they skip.
CGO_ENABLED=0 go test -v ./...

# The stubs and the whole menu model, off macOS.
CGO_ENABLED=0 GOOS=linux go test ./...

# A real menu-bar application, in the menu bar.
go run ./cmd/statusitemdemo

The portable menu model — validation, separator and disabled-row classification, tag assignment, tag dispatch, the per-item registry — is at 100% statement coverage, gated in CI by shape (anything that is not _<goos>.go and not under cmd/) on both the darwin and the linux lane, and run, not merely compiled, on all six of Go's 64-bit architectures.

The live suite asserts properties, never "it did not crash": the button's title read back out of AppKit, numberOfItems, each row's isSeparatorItem/isEnabled/tag/target, the item window being ordered out by Close, and the Go handler firing through AppKit's own -performActionForItemAtIndex:. Every test carries a negative control — a nonexistent class must yield nil, the disabled row and the separator must dispatch nothing, two items must not share a tag space — because an assertion that cannot fail is not a test.

Platforms

macOS only. Every other platform compiles and reports ErrUnsupported, so consumers cross-compile without a build tag of their own — verified on linux/{amd64,arm64,riscv64,loong64,ppc64le,s390x}, windows/{amd64,arm64}, darwin/amd64, android/arm64 and js/wasm. What is not stubbed out is the menu model: it behaves identically everywhere, which is what makes it testable off a Mac.

Off darwin New still validates before it reports ErrUnsupported, so a developer working on Linux is told about a malformed menu there instead of discovering it on the Mac where it is finally built.

Licence

BSD-3-Clause.

Documentation

Overview

Package statusitem puts an item in the macOS menu bar — the thing everyone calls a "tray icon" — from pure Go, with CGO_ENABLED=0. It reaches AppKit through github.com/go-macos/objc, which reaches it through purego: no cgo, no osascript, no Objective-C source file anywhere in the build.

A MenuItem carries a title, an optional key equivalent and a Go func. That func is called when the row is chosen.

item, err := statusitem.New("⌘", []statusitem.MenuItem{
	{Title: "Preferences…", Key: ",", Do: openPreferences},
	{}, // a separator
	{Title: "Quit", Key: "q", Do: quit},
})
if err != nil {
	return err
}
defer item.Close()

It needs a host application, and it says so

This package does NOT create an application. It expects to be called from a process that already has an NSApplication whose run loop is running on the process main OS thread — in this fleet that is go-widgets/window, in a plain binary it is objc.RunApp. Everything about a status item depends on that loop:

  • +[NSStatusBar systemStatusBar] and -statusItemWithLength: succeed without it, so an item can be BUILT before the loop starts. New is therefore safe to call during start-up.
  • Nothing is ever DRAWN and no row is ever chosen without it. A status item in a process whose main thread never runs a loop is an object with no window, and it is indistinguishable — from Go — from one that works.

If there is no NSApplication at all, New creates the shared one (+sharedApplication) as a side effect, because -systemStatusBar is meaningless before AppKit has an application object. It does not call -finishLaunching and it does not touch the activation policy: a host owns those, and changing them under a host would move its Dock tile or its main menu.

The main thread

AppKit is main-thread-only, and violating that does not fail politely: the observed failure mode in this fleet's own tray code was an Objective-C exception and a SIGABRT, intermittently, depending on which OS thread the Go scheduler happened to be running the goroutine on.

So every AppKit call this package makes is marshalled onto the process main thread, and every exported function is safe to call from any goroutine. -[NSThread isMainThread] decides how: on the main thread the work runs inline (so New works before the loop is started), and off it the work is queued with -performSelectorOnMainThread:withObject:waitUntilDone:NO and waited for with a timeout. The timeout is not decoration. waitUntilDone:YES from a goroutine in a process whose main thread is not running a loop blocks that goroutine FOREVER, with no error and no stack that mentions this package; after MainHopTimeout the call reports ErrNoMainLoop instead.

Handlers do not run on the main thread

MenuItem.Do is called on a fresh goroutine, not on the main thread that delivered the click. A handler that blocks — a network fetch, a lock, a channel nobody is reading — would otherwise freeze the menu bar of the whole session, not merely this application. The cost is that two rapid choices can run concurrently and that a handler touching AppKit must marshal itself back; that is the cheaper of the two mistakes.

Portability

Every exported symbol is defined on all platforms, so a consumer cross-compiles without a build tag of its own; off darwin the entry points report ErrUnsupported. What is NOT stubbed out is the menu model — item validation, separator and disabled-row classification, tag assignment and tag dispatch all live in the portable file and behave identically everywhere, which is what lets them be tested to the last branch on a Linux runner with no window server in sight.

Index

Constants

View Source
const MainHopTimeout = 5 * time.Second

MainHopTimeout is how long an exported call made from a goroutine other than the main one waits for the process main thread to service its AppKit work before reporting ErrNoMainLoop.

It is generous because it is not a performance budget: a main thread that is running a loop at all services the request in microseconds, and one that is not will never service it. The only case in between is a main thread busy inside a handler of its own, and five seconds of that is already a bug elsewhere.

Variables

View Source
var (
	// ErrUnsupported is returned by every entry point on non-darwin platforms
	// (a menu bar of this shape is AppKit's, and AppKit is macOS-only).
	ErrUnsupported = errors.New("statusitem: unsupported on this platform (darwin only)")

	// ErrClosed reports use of an [Item] that has already been removed from
	// the menu bar.
	ErrClosed = errors.New("statusitem: status item already removed")

	// ErrEmptyTitle reports an empty status-item title. AppKit accepts one and
	// draws a zero-width item: present in the menu bar, impossible to see and
	// impossible to click. There is no image parameter here to make an empty
	// title mean something, so it is refused rather than shipped as a mystery.
	ErrEmptyTitle = errors.New("statusitem: an empty title would be an invisible status item")

	// ErrHasNUL reports a title or key equivalent containing a NUL byte. The
	// Objective-C string bridge is +stringWithUTF8String:, which terminates at
	// the first NUL, so such a string would be silently TRUNCATED rather than
	// rejected — the caller would see a shorter menu row and no error at all.
	ErrHasNUL = errors.New("statusitem: title or key equivalent contains a NUL byte")

	// ErrSeparatorNotEmpty reports a row with an empty Title that also carries
	// a Do or a Key. An empty title is how this package spells "separator", and
	// a separator cannot be chosen, so the Do could never run. Accepting it
	// would mean silently dropping a handler the caller wrote on purpose.
	ErrSeparatorNotEmpty = errors.New("statusitem: a row with an empty title is a separator and can carry neither Do nor Key")

	// ErrKeyNotOneRune reports a key equivalent that is not exactly one
	// character. -[NSMenuItem setKeyEquivalent:] accepts any string, shows it
	// in the row, and then never matches it against a keystroke: the shortcut
	// is drawn and dead.
	ErrKeyNotOneRune = errors.New("statusitem: a key equivalent must be exactly one character")

	// ErrNoMainLoop reports that the process main thread did not service an
	// AppKit request within [MainHopTimeout]. It means no run loop is running
	// there — the caller is a goroutine in a process that has not started one,
	// or has stopped it. Without this the goroutine would block forever.
	ErrNoMainLoop = errors.New("statusitem: the main thread did not service the request (no run loop is running there)")

	// ErrNoTargetClass reports that the runtime class carrying the menu action
	// could not be created. Every menu row needs it as its target, so there is
	// nothing to hand back: an item whose rows have a nil target draws perfectly
	// and answers no click.
	ErrNoTargetClass = errors.New("statusitem: the Objective-C action target class could not be created")

	// ErrNoApplication reports that +[NSApplication sharedApplication] yielded
	// nil. AppKit has no application object, so there is no menu bar to join.
	ErrNoApplication = errors.New("statusitem: +[NSApplication sharedApplication] returned nil")

	// ErrNoButton reports that the status item has no -button. The item exists
	// but has nothing to draw a title in, which is the shape AppKit takes when
	// the process may not draw in the menu bar at all.
	ErrNoButton = errors.New("statusitem: the status item has no button to put a title in")
	// ErrNoSymbol means the name is not a symbol this system has, or is not a
	// name at all. The item keeps whatever it was showing: one that quietly
	// became blank is one nobody can find.
	ErrNoSymbol = errors.New("statusitem: no such system symbol")

	// ErrNoStatusBar reports that +[NSStatusBar systemStatusBar] returned nil.
	// There is no menu bar to put an item in: no window server, or a session
	// that has none.
	ErrNoStatusBar = errors.New("statusitem: +[NSStatusBar systemStatusBar] returned nil (no menu bar in this session)")
)

Errors reported by the package. They are stable and may be tested with errors.Is.

Functions

This section is empty.

Types

type Item

type Item struct {
	// contains filtered or unexported fields
}

Item is a status item in the menu bar. On non-darwin platforms one can never be created, so no value of this type is ever handed out by New; the type exists so that consumer code naming it still compiles.

func New

func New(title string, items []MenuItem) (*Item, error)

New reports ErrUnsupported: the menu bar this package fills is AppKit's.

It validates its arguments FIRST, so that a developer working on Linux gets exactly the complaint macOS would make about a malformed menu — an empty title, a separator carrying a handler, a two-character shortcut — instead of a blanket ErrUnsupported that hides the defect until the code is built on a Mac.

func (*Item) Close

func (i *Item) Close() error

Close reports ErrUnsupported. There is nothing in a menu bar to remove.

func (*Item) OnScreen added in v0.2.0

func (i *Item) OnScreen() (bool, error)

OnScreen reports ErrUnsupported: there is no menu bar here.

func (*Item) SetMenu

func (i *Item) SetMenu(items []MenuItem) error

SetMenu reports ErrUnsupported. The rows are still validated first, for the same reason New validates.

func (*Item) SetSymbol added in v0.3.0

func (i *Item) SetSymbol(name, description string) error

SetSymbol reports ErrUnsupported: there is no menu bar here to draw in.

func (*Item) SetTitle

func (i *Item) SetTitle(s string) error

SetTitle reports ErrUnsupported. The title is still validated first, for the same reason New validates.

type MenuItem struct {
	// Title is the text of the row. An empty Title makes the row a separator,
	// in which case Key and Do must both be zero.
	Title string
	// Key is an optional key equivalent: exactly one character, taken with
	// Command. "," gives ⌘, — the conventional Preferences shortcut.
	Key string
	// Do is called when the row is chosen. A nil Do makes the row disabled.
	Do func()
}

MenuItem is one row of a status item's menu.

The zero value is a separator. A row with a Title and no Do is a disabled row — a heading, or a value shown for information. A row with a Title and a Do is chooseable, and the Do is called on a fresh goroutine (see the package documentation for why not on the main thread).

func (m MenuItem) IsSeparator() bool

IsSeparator reports whether the row is a separator, which is exactly the case where Title is empty. It is the rule the rest of the package applies, exported so that a caller building rows programmatically can apply the same one instead of guessing at it.

Directories

Path Synopsis
cmd
statusitemdemo command
Command statusitemdemo puts a status item in the macOS menu bar and waits for it to be used.
Command statusitemdemo puts a status item in the macOS menu bar and waits for it to be used.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL