DEV Community

Cover image for We Built a Pure Go System Tray Library Because Every Alternative Requires CGO, GoGPU May 2026
Andrey Kolkov
Andrey Kolkov

Posted on

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

"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=linux from macOS? Good luck with CGO)
  • Larger binaries, slower builds
  • CGO_ENABLED=0 doesn'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.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.

macOS: NSStatusBar via ObjC Runtime

This is where it gets interesting. Calling AppKit without CGO requires speaking the Objective-C runtime protocol:

  1. objc_getClass("NSStatusBar") — get the class
  2. objc_msgSend(class, sel("systemStatusBar")) — get the shared status bar
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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
  • GetLayout returns the full tree, Event dispatches 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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pulls in GTK3 runtime — gigantic dependency for a tray icon
  2. It's just a wrapper around SNI — AppIndicator talks D-Bus SNI internally
  3. Icon caching bugs — AppIndicator caches icons by filename, causing stale icons
  4. 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.
Enter fullscreen mode Exit fullscreen mode

Try It

go get github.com/gogpu/systray@v0.1.0
Enter fullscreen mode Exit fullscreen mode

Run the example:

git clone https://github.com/gogpu/systray
cd systray/examples/basic
go run .
Enter fullscreen mode Exit fullscreen mode

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)