Tools: We Built a Pure Go System Tray Library Because Every Alternative Requires CGO, GoGPU May 2026

Tools: We Built a Pure Go System Tray Library Because Every Alternative Requires CGO, GoGPU May 2026

The Problem

The Solution: Native APIs via Pure Go FFI

Windows: Shell_NotifyIconW

macOS: NSStatusBar via ObjC Runtime

Linux: D-Bus StatusNotifierItem

The API

Enterprise Research

Why Not AppIndicator on Linux?

The Numbers

Try It

Part of GoGPU "Love the no CGO — but quickly realized there's no code?"

— @cmilesio, gogpu/systray#1 Fair point. We published the repo with just a README and a dream. Three days later: 5,800+ lines of Pure Go, three platforms, 74 tests, 84% coverage, and a working system tray icon on Windows. Today we're releasing gogpu/systray v0.1.0 — the first Pure Go system tray library that works on Windows, macOS, and Linux without a C compiler. Every Go system tray library requires CGO: Go is famous for "single binary, cross-compile anywhere." CGO breaks that promise. We went platform-native without CGO: No C compiler. No shared libraries. No dlopen of GTK. Just Go talking directly to the OS. The Win32 approach is straightforward — Shell_NotifyIconW has been the tray API since Windows 95. We call it via golang.org/x/sys/windows, the same way the Go standard library talks to Windows. This is where it gets interesting. Calling AppKit without CGO requires speaking the Objective-C runtime protocol: We built a minimal ObjC runtime wrapper (~490 LOC) using goffi — our Pure Go FFI library. Same approach we use for the Metal GPU backend in gogpu/wgpu. The killer feature on macOS: template icons. This calls [NSImage setTemplate:YES], telling macOS the icon is a monochrome mask. The OS automatically renders it white on dark menu bars, black on light ones. No dark mode handling needed — Apple does it for you. Linux is the most complex platform. The "system tray" isn't a single API — it's a D-Bus protocol called StatusNotifierItem (SNI). We implement two D-Bus interfaces: org.kde.StatusNotifierItem — the tray icon itself: com.canonical.dbusmenu — the context menu: And a registration dance with org.kde.StatusNotifierWatcher — plus automatic re-registration when the desktop panel restarts. The PNG→ARGB conversion is a fun detail: SNI wants ARGB32 in network byte order (big-endian), so we decode the PNG with image/png and manually pack [A, R, G, B] bytes. All of this via godbus/dbus/v5 — the canonical Pure Go D-Bus library. Zero CGO. We went with a builder pattern inspired by Wails 3: Multiple trays are supported — each call to systray.New() creates an independent icon: We didn't guess at the architecture. Before writing code, we studied how the big frameworks do it: The architecture follows Qt6's QPlatformSystemTrayIcon pattern: The tempting path: dlopen("libayatana-appindicator3.so.1") and let GTK3 handle everything. That's what getlantern/systray does (via CGO). We cut out the middleman. D-Bus SNI directly via godbus — same protocol, no GTK, no CGO. A green icon appears in your system tray. Right-click for the menu. Toggle dark mode to see auto-switching. We need testers — especially on macOS and Linux (KDE, GNOME + AppIndicator extension, XFCE, Sway). File issues if something doesn't work. systray is standalone (go get github.com/gogpu/systray — no gogpu dependency), but it's designed to integrate with the GoGPU ecosystem — 800K+ lines of Pure Go GPU code: All Pure Go. All zero CGO. All cross-platform. Four gogpu libraries are listed in awesome-go: systray (GUI Interaction), ui (GUI Toolkits), gg (Images), gogpu (Game Development). If you build something with systray, let us know. Star ⭐ the repo if you find it useful — it helps others discover the project. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

tray.SetTemplateIcon(monochromePNG) tray.SetTemplateIcon(monochromePNG) tray.SetTemplateIcon(monochromePNG) package main import ( "fmt" "os" "github.com/gogpu/systray" ) func main() { tray := systray.New() menu := systray.NewMenu() menu.Add("Open", func() { fmt.Println("Opening...") }) menu.AddSeparator() menu.AddCheckbox("Dark Mode", false, func() { fmt.Println("Toggled!") }) menu.AddSubmenu("More...", systray.NewMenu(). Add("About", func() { fmt.Println("v1.0") }). Add("Help", func() { fmt.Println("Help!") })) menu.AddSeparator() menu.Add("Quit", func() { tray.Remove(); os.Exit(0) }) tray.SetIcon(iconPNG). SetDarkModeIcon(darkIconPNG). SetTooltip("My App"). SetMenu(menu). Show() tray.OnClick(func() { fmt.Println("Clicked!") }) tray.Run() // blocks, pumps platform messages } package main import ( "fmt" "os" "github.com/gogpu/systray" ) func main() { tray := systray.New() menu := systray.NewMenu() menu.Add("Open", func() { fmt.Println("Opening...") }) menu.AddSeparator() menu.AddCheckbox("Dark Mode", false, func() { fmt.Println("Toggled!") }) menu.AddSubmenu("More...", systray.NewMenu(). Add("About", func() { fmt.Println("v1.0") }). Add("Help", func() { fmt.Println("Help!") })) menu.AddSeparator() menu.Add("Quit", func() { tray.Remove(); os.Exit(0) }) tray.SetIcon(iconPNG). SetDarkModeIcon(darkIconPNG). SetTooltip("My App"). SetMenu(menu). Show() tray.OnClick(func() { fmt.Println("Clicked!") }) tray.Run() // blocks, pumps platform messages } package main import ( "fmt" "os" "github.com/gogpu/systray" ) func main() { tray := systray.New() menu := systray.NewMenu() menu.Add("Open", func() { fmt.Println("Opening...") }) menu.AddSeparator() menu.AddCheckbox("Dark Mode", false, func() { fmt.Println("Toggled!") }) menu.AddSubmenu("More...", systray.NewMenu(). Add("About", func() { fmt.Println("v1.0") }). Add("Help", func() { fmt.Println("Help!") })) menu.AddSeparator() menu.Add("Quit", func() { tray.Remove(); os.Exit(0) }) tray.SetIcon(iconPNG). SetDarkModeIcon(darkIconPNG). SetTooltip("My App"). SetMenu(menu). Show() tray.OnClick(func() { fmt.Println("Clicked!") }) tray.Run() // blocks, pumps platform messages } mainTray := systray.New().SetIcon(appIcon).SetMenu(mainMenu).Show() statusTray := systray.New().SetIcon(statusIcon).SetTooltip("Status: OK").Show() mainTray := systray.New().SetIcon(appIcon).SetMenu(mainMenu).Show() statusTray := systray.New().SetIcon(statusIcon).SetTooltip("Status: OK").Show() mainTray := systray.New().SetIcon(appIcon).SetMenu(mainMenu).Show() statusTray := systray.New().SetIcon(statusIcon).SetTooltip("Status: OK").Show() systray.New() → SystemTray (public API, delegation) │ PlatformTray (internal interface) │ ┌────────────┼────────────┐ Win32 impl macOS impl Linux impl Shell_Notify NSStatusBar D-Bus SNI systray.New() → SystemTray (public API, delegation) │ PlatformTray (internal interface) │ ┌────────────┼────────────┐ Win32 impl macOS impl Linux impl Shell_Notify NSStatusBar D-Bus SNI systray.New() → SystemTray (public API, delegation) │ PlatformTray (internal interface) │ ┌────────────┼────────────┐ Win32 impl macOS impl Linux impl Shell_Notify NSStatusBar D-Bus SNI Total: ~5,800 lines of Pure Go (6,900 with docs/CI/configs) Tests: 74 (84% public API coverage) Platforms: Windows ✅, macOS ✅, Linux ✅ Deps: golang.org/x/sys, go-webgpu/goffi, godbus/dbus/v5 CGO: Zero. Absolutely zero. Total: ~5,800 lines of Pure Go (6,900 with docs/CI/configs) Tests: 74 (84% public API coverage) Platforms: Windows ✅, macOS ✅, Linux ✅ Deps: golang.org/x/sys, go-webgpu/goffi, godbus/dbus/v5 CGO: Zero. Absolutely zero. Total: ~5,800 lines of Pure Go (6,900 with docs/CI/configs) Tests: 74 (84% public API coverage) Platforms: Windows ✅, macOS ✅, Linux ✅ Deps: golang.org/x/sys, go-webgpu/goffi, godbus/dbus/v5 CGO: Zero. Absolutely zero. go get github.com/gogpu/[email protected] go get github.com/gogpu/[email protected] go get github.com/gogpu/[email protected] git clone https://github.com/gogpu/systray cd systray/examples/basic go run . git clone https://github.com/gogpu/systray cd systray/examples/basic go run . git clone https://github.com/gogpu/systray cd systray/examples/basic go run . - Need a C compiler installed (apt install gcc, Xcode, MinGW) - Cross-compilation breaks (GOOS=linux from macOS? Good luck with CGO) - Larger binaries, slower builds - CGO_ENABLED=0 doesn't work - Message-only HWND for callbacks (invisible, no taskbar entry) - NOTIFYICON_VERSION_4 for modern event dispatch - Explorer crash recovery — when explorer.exe restarts, tray icons disappear. We listen for the TaskbarCreated registered message and re-add the icon automatically. - Dark mode auto-switching — detect WM_SETTINGCHANGE + ImmersiveColorSet, read SystemUsesLightTheme registry key, swap HICON. Your tray icon adapts when the user toggles Windows dark mode. - objc_getClass("NSStatusBar") — get the class - objc_msgSend(class, sel("systemStatusBar")) — get the shared status bar - objc_msgSend(statusBar, sel("statusItemWithLength:"), -1.0) — create a status item - Properties: Category, Id, Title, Status, IconPixmap, ToolTip, Menu - Methods: Activate (click), SecondaryActivate (middle-click), ContextMenu (right-click) - Signals: NewIcon, NewTitle, NewStatus - A recursive tree of menu items with labels, types, toggle states - GetLayout returns the full tree, Event dispatches clicks - Pulls in GTK3 runtime — gigantic dependency for a tray icon - It's just a wrapper around SNI — AppIndicator talks D-Bus SNI internally - Icon caching bugs — AppIndicator caches icons by filename, causing stale icons - Not available everywhere — minimal compositors (Sway, Hyprland) don't have AppIndicator