"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.
The Problem
Every Go system tray library requires CGO:
| Library | Stars | CGO? | The Catch |
|---|---|---|---|
| getlantern/systray | 3.3K | Yes (macOS, Linux) | AppIndicator + GTK3 on Linux, Cocoa via CGO on macOS |
| fyne-io/systray | fork | Yes (macOS, Linux) | Same CGO deps, fork of getlantern |
| energye/systray | — | Yes | Walk/LCL dependency |
CGO means:
- Need a C compiler installed (
apt install gcc, Xcode, MinGW) - Cross-compilation breaks (
GOOS=linuxfrom macOS? Good luck with CGO) - Larger binaries, slower builds
-
CGO_ENABLED=0doesn't work
Go is famous for "single binary, cross-compile anywhere." CGO breaks that promise.
The Solution: Native APIs via Pure Go FFI
We went platform-native without CGO:
| Platform | Native API | Go FFI | LOC |
|---|---|---|---|
| Windows |
Shell_NotifyIconW (shell32.dll) |
golang.org/x/sys/windows |
1,027 |
| macOS |
NSStatusBar / NSStatusItem (AppKit) |
go-webgpu/goffi (ObjC runtime) |
1,385 |
| Linux | StatusNotifierItem (D-Bus SNI) | godbus/dbus/v5 |
810 |
No C compiler. No shared libraries. No dlopen of GTK. Just Go talking directly to the OS.
Windows: Shell_NotifyIconW
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.
Key details:
- Message-only HWND for callbacks (invisible, no taskbar entry)
- NOTIFYICON_VERSION_4 for modern event dispatch
-
Explorer crash recovery — when
explorer.exerestarts, tray icons disappear. We listen for theTaskbarCreatedregistered message and re-add the icon automatically. -
Dark mode auto-switching — detect
WM_SETTINGCHANGE+ImmersiveColorSet, readSystemUsesLightThemeregistry key, swap HICON. Your tray icon adapts when the user toggles Windows dark mode.
macOS: NSStatusBar via ObjC Runtime
This is where it gets interesting. Calling AppKit without CGO requires speaking the Objective-C runtime protocol:
-
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
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.
tray.SetTemplateIcon(monochromePNG)
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: D-Bus StatusNotifierItem
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:
- Properties:
Category,Id,Title,Status,IconPixmap,ToolTip,Menu - Methods:
Activate(click),SecondaryActivate(middle-click),ContextMenu(right-click) - Signals:
NewIcon,NewTitle,NewStatus
com.canonical.dbusmenu — the context menu:
- A recursive tree of menu items with labels, types, toggle states
-
GetLayoutreturns the full tree,Eventdispatches clicks
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.
The API
We went with a builder pattern inspired by Wails 3:
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
}
Multiple trays are supported — each call to systray.New() creates an independent icon:
mainTray := systray.New().SetIcon(appIcon).SetMenu(mainMenu).Show()
statusTray := systray.New().SetIcon(statusIcon).SetTooltip("Status: OK").Show()
Enterprise Research
We didn't guess at the architecture. Before writing code, we studied how the big frameworks do it:
| Framework | Tray Architecture | Our Takeaway |
|---|---|---|
| Qt6 |
QPlatformSystemTrayIcon → 3 platform implementations |
Three-layer pattern (public API → interface → platform impl) |
| Wails 3 |
systemTrayImpl interface, native per-platform |
Builder API pattern, multiple tray support |
| SDL3 | 4 backends (AppIndicator, D-Bus, Win32, Cocoa) | We chose D-Bus SNI directly, skipping AppIndicator |
| Electron | nativeTheme.shouldUseDarkColors |
Dark mode detection via WM_SETTINGCHANGE
|
| GLFW |
RemovePropW before DestroyWindow
|
Destroy pattern (avoid deadlocks) |
| getlantern/systray |
GetMessage loop, hidden WS_OVERLAPPEDWINDOW |
Message pump pattern (we use HWND_MESSAGE instead) |
The architecture follows Qt6's QPlatformSystemTrayIcon pattern:
systray.New() → SystemTray (public API, delegation)
│
PlatformTray (internal interface)
│
┌────────────┼────────────┐
Win32 impl macOS impl Linux impl
Shell_Notify NSStatusBar D-Bus SNI
Why Not AppIndicator on Linux?
The tempting path: dlopen("libayatana-appindicator3.so.1") and let GTK3 handle everything. That's what getlantern/systray does (via CGO).
Problems:
- 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
We cut out the middleman. D-Bus SNI directly via godbus — same protocol, no GTK, no CGO.
The Numbers
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.
Try It
go get github.com/gogpu/systray@v0.1.0
Run the example:
git clone https://github.com/gogpu/systray
cd systray/examples/basic
go run .
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.
Part of GoGPU
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:
| Library | What It Does |
|---|---|
| wgpu | Pure Go WebGPU (Vulkan/Metal/DX12/GLES) |
| naga | Shader compiler (WGSL → SPIR-V/MSL/GLSL/HLSL/DXIL) |
| gg | 2D graphics (Skia-class rasterizer) |
| gogpu | App framework, windowing, input |
| ui | GUI toolkit (22+ widgets, Material 3) |
| systray | System tray (this library) |
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.
Top comments (0)