package main
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
)
// Linux tray, implemented as a minimal StatusNotifierItem (+ dbusmenu) server
// directly over D-Bus. Ported from a working reference on this same machine:
// Frequency's gui.py `_Tray` class (/opt/frequency/gui.py). GTK/Wails have no
// portable tray API, and the common Go systray wrappers pull in libappindicator
// or ship raw icon pixmap bytes - the latter is exactly what breaks GNOME's
// AppIndicator extension (crashes with "can't access property 'clone'",
// documented in Cascade's README). Using an IconName resolved via our own
// IconThemePath (not the user's ~/.local/share/icons - nothing to cache-
// invalidate) sidesteps that entirely, same as both reference apps do it.
//go:embed assets/atk-tray-symbolic.svg
var trayIconSVG []byte
const (
trayIconName = "atk-tray-symbolic"
sniPath = dbus.ObjectPath("/StatusNotifierItem")
menuPath = dbus.ObjectPath("/MenuBar")
)
var (
trayMu sync.Mutex
trayState *linuxTray
)
type linuxTray struct {
app *App
conn *dbus.Conn
iconDir string
ok bool
}
func initTray(a *App) {
trayMu.Lock()
defer trayMu.Unlock()
if trayState != nil {
return
}
t := &linuxTray{app: a}
if err := t.start(); err != nil {
// Non-fatal: no session bus, no StatusNotifierWatcher (extension not
// installed/enabled), etc. ATK just runs without a tray icon.
t.ok = false
}
trayState = t
}
func closeTray() {
trayMu.Lock()
defer trayMu.Unlock()
if trayState == nil || trayState.conn == nil {
return
}
_ = trayState.conn.Close()
trayState = nil
}
func trayAvailable() bool {
trayMu.Lock()
defer trayMu.Unlock()
return trayState != nil && trayState.ok
}
func (t *linuxTray) start() error {
if err := t.writeIcon(); err != nil {
return err
}
conn, err := dbus.ConnectSessionBus()
if err != nil {
return err
}
t.conn = conn
if err := t.exportSNI(); err != nil {
return err
}
if err := t.exportMenu(); err != nil {
return err
}
name := fmt.Sprintf("org.kde.StatusNotifierItem-%d-1", os.Getpid())
reply, err := conn.RequestName(name, dbus.NameFlagDoNotQueue)
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
return fmt.Errorf("could not own tray bus name")
}
watcher := conn.Object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher")
call := watcher.Call("org.kde.StatusNotifierWatcher.RegisterStatusNotifierItem", 0, name)
if call.Err != nil {
// No watcher running (AppIndicator extension off, or a DE without one).
return call.Err
}
t.ok = true
return nil
}
// writeIcon materialises the embedded SVG to an app-owned directory and
// advertises that directory via IconThemePath, rather than installing into
// the user's shared ~/.local/share/icons (no icon-cache/gtk-update step
// needed - the same approach Frequency uses via its bundled icons/ dir).
func (t *linuxTray) writeIcon() error {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = os.TempDir()
}
dir := filepath.Join(cacheDir, "atk", "tray-icons")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, trayIconName+".svg")
if err := os.WriteFile(path, trayIconSVG, 0o644); err != nil {
return err
}
t.iconDir = dir
return nil
}
// --- org.kde.StatusNotifierItem -------------------------------------------
const sniIntrospectXML = `
`
type sniHandler struct{ t *linuxTray }
func (h *sniHandler) Activate(x, y int32) *dbus.Error {
h.t.app.ToggleWindow()
return nil
}
func (h *sniHandler) SecondaryActivate(x, y int32) *dbus.Error {
h.t.app.ToggleWindow()
return nil
}
func (h *sniHandler) ContextMenu(x, y int32) *dbus.Error { return nil }
func (h *sniHandler) Scroll(delta int32, orientation string) *dbus.Error { return nil }
func (h *sniHandler) sniProps() map[string]dbus.Variant {
return map[string]dbus.Variant{
"Category": dbus.MakeVariant("ApplicationStatus"),
"Id": dbus.MakeVariant("atk"),
"Title": dbus.MakeVariant("ATK"),
"Status": dbus.MakeVariant("Active"),
"IconName": dbus.MakeVariant(trayIconName),
"IconThemePath": dbus.MakeVariant(h.t.iconDir),
"Menu": dbus.MakeVariant(menuPath),
"ItemIsMenu": dbus.MakeVariant(false),
}
}
func (h *sniHandler) Get(iface, propName string) (dbus.Variant, *dbus.Error) {
v, ok := h.sniProps()[propName]
if !ok {
return dbus.Variant{}, dbus.NewError("org.freedesktop.DBus.Error.UnknownProperty", nil)
}
return v, nil
}
func (h *sniHandler) GetAll(iface string) (map[string]dbus.Variant, *dbus.Error) {
return h.sniProps(), nil
}
func (h *sniHandler) Set(iface, propName string, value dbus.Variant) *dbus.Error {
return dbus.NewError("org.freedesktop.DBus.Error.PropertyReadOnly", nil)
}
func (t *linuxTray) exportSNI() error {
h := &sniHandler{t: t}
if err := t.conn.Export(h, sniPath, "org.kde.StatusNotifierItem"); err != nil {
return err
}
if err := t.conn.Export(h, sniPath, "org.freedesktop.DBus.Properties"); err != nil {
return err
}
node := `` + sniIntrospectXML + ``
return t.conn.Export(introspect.Introspectable(node), sniPath, "org.freedesktop.DBus.Introspectable")
}
// --- com.canonical.dbusmenu -------------------------------------------
const menuIntrospectXML = `
`
// menuNode mirrors dbusmenu's recursive (ia{sv}av) layout struct: id,
// properties, children (each child a variant wrapping another menuNode).
type menuNode struct {
ID int32
Props map[string]dbus.Variant
Children []dbus.Variant
}
type groupProps struct {
ID int32
Props map[string]dbus.Variant
}
// menuItem ids: 0 is reserved for the menu root (GetLayout's implicit parent),
// so item/separator ids start at 1 and must all stay distinct from it and
// from each other.
type menuItemDef struct {
id int32
label string // empty => separator
}
func menuItems() []menuItemDef {
return []menuItemDef{
{1, "Show / Hide ATK"},
{2, ""}, // separator
{3, "Quit ATK"},
}
}
func menuItemProps(item menuItemDef) map[string]dbus.Variant {
if item.label == "" {
return map[string]dbus.Variant{"type": dbus.MakeVariant("separator")}
}
return map[string]dbus.Variant{
"label": dbus.MakeVariant(item.label),
"enabled": dbus.MakeVariant(true),
}
}
type menuHandler struct{ t *linuxTray }
func (h *menuHandler) GetLayout(parentID, recursionDepth int32, propertyNames []string) (uint32, menuNode, *dbus.Error) {
var children []dbus.Variant
for _, item := range menuItems() {
children = append(children, dbus.MakeVariant(menuNode{
ID: item.id,
Props: menuItemProps(item),
Children: nil,
}))
}
root := menuNode{
ID: 0,
Props: map[string]dbus.Variant{"children-display": dbus.MakeVariant("submenu")},
Children: children,
}
return 1, root, nil
}
func (h *menuHandler) GetGroupProperties(ids []int32, propertyNames []string) ([]groupProps, *dbus.Error) {
wanted := make(map[int32]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
var out []groupProps
for _, item := range menuItems() {
if wanted[item.id] {
out = append(out, groupProps{ID: item.id, Props: menuItemProps(item)})
}
}
return out, nil
}
func (h *menuHandler) Event(id int32, eventID string, data dbus.Variant, timestamp uint32) *dbus.Error {
if eventID != "clicked" {
return nil
}
switch id {
case 1:
h.t.app.ToggleWindow()
case 3:
h.t.app.QuitApp()
}
return nil
}
func (h *menuHandler) AboutToShow(id int32) (bool, *dbus.Error) { return false, nil }
func (h *menuHandler) menuProps() map[string]dbus.Variant {
return map[string]dbus.Variant{
"Version": dbus.MakeVariant(uint32(3)),
"Status": dbus.MakeVariant("normal"),
}
}
func (h *menuHandler) Get(iface, propName string) (dbus.Variant, *dbus.Error) {
v, ok := h.menuProps()[propName]
if !ok {
return dbus.Variant{}, dbus.NewError("org.freedesktop.DBus.Error.UnknownProperty", nil)
}
return v, nil
}
func (h *menuHandler) GetAll(iface string) (map[string]dbus.Variant, *dbus.Error) {
return h.menuProps(), nil
}
func (h *menuHandler) Set(iface, propName string, value dbus.Variant) *dbus.Error {
return dbus.NewError("org.freedesktop.DBus.Error.PropertyReadOnly", nil)
}
func (t *linuxTray) exportMenu() error {
h := &menuHandler{t: t}
if err := t.conn.Export(h, menuPath, "com.canonical.dbusmenu"); err != nil {
return err
}
if err := t.conn.Export(h, menuPath, "org.freedesktop.DBus.Properties"); err != nil {
return err
}
node := `` + menuIntrospectXML + ``
return t.conn.Export(introspect.Introspectable(node), menuPath, "org.freedesktop.DBus.Introspectable")
}