feat(systray): add Manager with platform abstraction and callback registry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 12:13:40 +00:00
parent 7c066ba3d8
commit 940ed0bdae
8 changed files with 444 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

80
pkg/systray/menu.go Normal file
View file

@ -0,0 +1,80 @@
// pkg/systray/menu.go
package systray
import "fmt"
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
func (m *Manager) SetMenu(items []TrayMenuItem) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
menu := m.buildMenu(items)
m.tray.SetMenu(menu)
return nil
}
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
menu := m.platform.NewMenu()
for _, item := range items {
if item.Type == "separator" {
menu.AddSeparator()
continue
}
if len(item.Submenu) > 0 {
sub := m.buildMenu(item.Submenu)
mi := menu.Add(item.Label)
_ = mi.AddSubmenu()
_ = sub // TODO: wire sub into parent via platform
continue
}
mi := menu.Add(item.Label)
if item.Tooltip != "" {
mi.SetTooltip(item.Tooltip)
}
if item.Disabled {
mi.SetEnabled(false)
}
if item.Checked {
mi.SetChecked(true)
}
if item.ActionID != "" {
actionID := item.ActionID
mi.OnClick(func() {
if cb, ok := m.GetCallback(actionID); ok {
cb()
}
})
}
}
return menu
}
// RegisterCallback registers a callback for a menu action ID.
func (m *Manager) RegisterCallback(actionID string, callback func()) {
m.mu.Lock()
m.callbacks[actionID] = callback
m.mu.Unlock()
}
// UnregisterCallback removes a callback.
func (m *Manager) UnregisterCallback(actionID string) {
m.mu.Lock()
delete(m.callbacks, actionID)
m.mu.Unlock()
}
// GetCallback returns the callback for an action ID.
func (m *Manager) GetCallback(actionID string) (func(), bool) {
m.mu.RLock()
defer m.mu.RUnlock()
cb, ok := m.callbacks[actionID]
return cb, ok
}
// GetInfo returns tray status information.
func (m *Manager) GetInfo() map[string]any {
return map[string]any{
"active": m.IsActive(),
}
}

53
pkg/systray/mock_test.go Normal file
View file

@ -0,0 +1,53 @@
// pkg/systray/mock_test.go
package systray
type mockPlatform struct {
trays []*mockTray
menus []*mockTrayMenu
}
func newMockPlatform() *mockPlatform { return &mockPlatform{} }
func (p *mockPlatform) NewTray() PlatformTray {
t := &mockTray{}
p.trays = append(p.trays, t)
return t
}
func (p *mockPlatform) NewMenu() PlatformMenu {
m := &mockTrayMenu{}
p.menus = append(p.menus, m)
return m
}
type mockTrayMenu struct {
items []string
}
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
m.items = append(m.items, label)
return &mockTrayMenuItem{}
}
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
type mockTrayMenuItem struct{}
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
type mockTray struct {
icon, templateIcon []byte
tooltip, label string
menu PlatformMenu
attachedWindow WindowHandle
}
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
func (t *mockTray) SetLabel(text string) { t.label = text }
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }

44
pkg/systray/platform.go Normal file
View file

@ -0,0 +1,44 @@
// pkg/systray/platform.go
package systray
// Platform abstracts the system tray backend.
type Platform interface {
NewTray() PlatformTray
NewMenu() PlatformMenu // Menu factory for building tray menus
}
// PlatformTray is a live tray handle from the backend.
type PlatformTray interface {
SetIcon(data []byte)
SetTemplateIcon(data []byte)
SetTooltip(text string)
SetLabel(text string)
SetMenu(menu PlatformMenu)
AttachWindow(w WindowHandle)
}
// PlatformMenu is a tray menu built by the backend.
type PlatformMenu interface {
Add(label string) PlatformMenuItem
AddSeparator()
}
// PlatformMenuItem is a single item in a tray menu.
type PlatformMenuItem interface {
SetTooltip(text string)
SetChecked(checked bool)
SetEnabled(enabled bool)
OnClick(fn func())
AddSubmenu() PlatformMenu
}
// WindowHandle is a cross-package interface for window operations.
// Defined locally to avoid circular imports (display imports systray).
// pkg/window.PlatformWindow satisfies this implicitly.
type WindowHandle interface {
Name() string
Show()
Hide()
SetPosition(x, y int)
SetSize(width, height int)
}

95
pkg/systray/tray.go Normal file
View file

@ -0,0 +1,95 @@
// pkg/systray/tray.go
package systray
import (
_ "embed"
"fmt"
"sync"
)
//go:embed assets/apptray.png
var defaultIcon []byte
// Manager manages the system tray lifecycle.
// State that was previously in package-level vars is now on the Manager.
type Manager struct {
platform Platform
tray PlatformTray
callbacks map[string]func()
mu sync.RWMutex
}
// NewManager creates a systray Manager.
func NewManager(platform Platform) *Manager {
return &Manager{
platform: platform,
callbacks: make(map[string]func()),
}
}
// Setup creates the system tray with default icon and tooltip.
func (m *Manager) Setup(tooltip, label string) error {
m.tray = m.platform.NewTray()
if m.tray == nil {
return fmt.Errorf("platform returned nil tray")
}
m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip)
m.tray.SetLabel(label)
return nil
}
// SetIcon sets the tray icon.
func (m *Manager) SetIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.tray.SetIcon(data)
return nil
}
// SetTemplateIcon sets the template icon (macOS).
func (m *Manager) SetTemplateIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.tray.SetTemplateIcon(data)
return nil
}
// SetTooltip sets the tray tooltip.
func (m *Manager) SetTooltip(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.tray.SetTooltip(text)
return nil
}
// SetLabel sets the tray label.
func (m *Manager) SetLabel(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.tray.SetLabel(text)
return nil
}
// AttachWindow attaches a panel window to the tray.
func (m *Manager) AttachWindow(w WindowHandle) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.tray.AttachWindow(w)
return nil
}
// Tray returns the underlying platform tray for direct access.
func (m *Manager) Tray() PlatformTray {
return m.tray
}
// IsActive returns whether a tray has been created.
func (m *Manager) IsActive() bool {
return m.tray != nil
}

86
pkg/systray/tray_test.go Normal file
View file

@ -0,0 +1,86 @@
// pkg/systray/tray_test.go
package systray
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestManager() (*Manager, *mockPlatform) {
p := newMockPlatform()
return NewManager(p), p
}
func TestManager_Setup_Good(t *testing.T) {
m, p := newTestManager()
err := m.Setup("Core", "Core")
require.NoError(t, err)
assert.True(t, m.IsActive())
assert.Len(t, p.trays, 1)
assert.Equal(t, "Core", p.trays[0].tooltip)
assert.Equal(t, "Core", p.trays[0].label)
assert.NotEmpty(t, p.trays[0].templateIcon) // default icon embedded
}
func TestManager_SetIcon_Good(t *testing.T) {
m, p := newTestManager()
_ = m.Setup("Core", "Core")
err := m.SetIcon([]byte{1, 2, 3})
require.NoError(t, err)
assert.Equal(t, []byte{1, 2, 3}, p.trays[0].icon)
}
func TestManager_SetIcon_Bad(t *testing.T) {
m, _ := newTestManager()
err := m.SetIcon([]byte{1})
assert.Error(t, err) // tray not initialised
}
func TestManager_SetTooltip_Good(t *testing.T) {
m, p := newTestManager()
_ = m.Setup("Core", "Core")
_ = m.SetTooltip("New Tooltip")
assert.Equal(t, "New Tooltip", p.trays[0].tooltip)
}
func TestManager_SetLabel_Good(t *testing.T) {
m, p := newTestManager()
_ = m.Setup("Core", "Core")
_ = m.SetLabel("New Label")
assert.Equal(t, "New Label", p.trays[0].label)
}
func TestManager_RegisterCallback_Good(t *testing.T) {
m, _ := newTestManager()
called := false
m.RegisterCallback("test-action", func() { called = true })
cb, ok := m.GetCallback("test-action")
assert.True(t, ok)
cb()
assert.True(t, called)
}
func TestManager_RegisterCallback_Bad(t *testing.T) {
m, _ := newTestManager()
_, ok := m.GetCallback("nonexistent")
assert.False(t, ok)
}
func TestManager_UnregisterCallback_Good(t *testing.T) {
m, _ := newTestManager()
m.RegisterCallback("remove-me", func() {})
m.UnregisterCallback("remove-me")
_, ok := m.GetCallback("remove-me")
assert.False(t, ok)
}
func TestManager_GetInfo_Good(t *testing.T) {
m, _ := newTestManager()
info := m.GetInfo()
assert.False(t, info["active"].(bool))
_ = m.Setup("Core", "Core")
info = m.GetInfo()
assert.True(t, info["active"].(bool))
}

13
pkg/systray/types.go Normal file
View file

@ -0,0 +1,13 @@
// pkg/systray/types.go
package systray
// TrayMenuItem describes a menu item for dynamic tray menus.
type TrayMenuItem struct {
Label string `json:"label"`
Type string `json:"type"` // "normal", "separator", "checkbox", "radio"
Checked bool `json:"checked,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Tooltip string `json:"tooltip,omitempty"`
Submenu []TrayMenuItem `json:"submenu,omitempty"`
ActionID string `json:"action_id,omitempty"`
}

73
pkg/systray/wails.go Normal file
View file

@ -0,0 +1,73 @@
// pkg/systray/wails.go
package systray
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// WailsPlatform implements Platform using Wails v3.
type WailsPlatform struct {
app *application.App
}
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
func (wp *WailsPlatform) NewTray() PlatformTray {
return &wailsTray{tray: wp.app.SystemTray.New(), app: wp.app}
}
func (wp *WailsPlatform) NewMenu() PlatformMenu {
return &wailsTrayMenu{menu: wp.app.NewMenu()}
}
type wailsTray struct {
tray *application.SystemTray
app *application.App
}
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
if wm, ok := menu.(*wailsTrayMenu); ok {
wt.tray.SetMenu(wm.menu)
}
}
func (wt *wailsTray) AttachWindow(w WindowHandle) {
// Wails systray AttachWindow expects an application.Window interface.
// The caller must pass an appropriate wrapper.
}
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
type wailsTrayMenu struct {
menu *application.Menu
}
func (m *wailsTrayMenu) Add(label string) PlatformMenuItem {
return &wailsTrayMenuItem{item: m.menu.Add(label)}
}
func (m *wailsTrayMenu) AddSeparator() {
m.menu.AddSeparator()
}
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
type wailsTrayMenuItem struct {
item *application.MenuItem
}
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
mi.item.OnClick(func(ctx *application.Context) { fn() })
}
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
return &wailsTrayMenu{menu: application.NewMenu()}
}