feat(systray): add IPC layer — Service, Register factory, message types

Systray package is now a full core.Service with typed IPC messages.
Menu item clicks emit ActionTrayMenuItemClicked via IPC.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-13 13:26:18 +00:00
parent a59028f112
commit 56ef6f3928
4 changed files with 185 additions and 0 deletions

30
pkg/systray/messages.go Normal file
View file

@ -0,0 +1,30 @@
package systray
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
type TaskSetTrayIcon struct{ Data []byte }
// TaskSetTrayMenu sets the tray menu items.
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
// TaskShowPanel shows the tray panel window.
type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
type TaskHidePanel struct{}
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
// --- Actions ---
// ActionTrayClicked is broadcast when the tray icon is clicked.
type ActionTrayClicked struct{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
type ActionTrayMenuItemClicked struct{ ActionID string }

14
pkg/systray/register.go Normal file
View file

@ -0,0 +1,14 @@
package systray
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
manager: NewManager(p),
}, nil
}
}

78
pkg/systray/service.go Normal file
View file

@ -0,0 +1,78 @@
package systray
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the systray service.
type Options struct{}
// Service is a core.Service managing the system tray via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if tCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(tCfg)
}
}
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
// Apply config — tooltip, icon path, etc.
tooltip, _ := cfg["tooltip"].(string)
if tooltip == "" {
tooltip = "Core"
}
_ = s.manager.Setup(tooltip, tooltip)
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetTrayIcon:
return nil, true, s.manager.SetIcon(t.Data)
case TaskSetTrayMenu:
return nil, true, s.taskSetTrayMenu(t)
case TaskShowPanel:
// Panel show — deferred (requires WindowHandle integration)
return nil, true, nil
case TaskHidePanel:
// Panel hide — deferred (requires WindowHandle integration)
return nil, true, nil
default:
return nil, false, nil
}
}
func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
// Register IPC-emitting callbacks for each menu item
for _, item := range t.Items {
if item.ActionID != "" {
actionID := item.ActionID
s.manager.RegisterCallback(actionID, func() {
_ = s.Core().ACTION(ActionTrayMenuItemClicked{ActionID: actionID})
})
}
}
return s.manager.SetMenu(t.Items)
}
// Manager returns the underlying systray Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -0,0 +1,63 @@
package systray
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
t.Helper()
c, err := core.New(
core.WithService(Register(newMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "systray")
return svc, c
}
func TestRegister_Good(t *testing.T) {
svc, _ := newTestSystrayService(t)
assert.NotNil(t, svc)
assert.NotNil(t, svc.manager)
}
func TestTaskSetTrayIcon_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
// Setup tray first (normally done via config in OnStartup)
require.NoError(t, svc.manager.Setup("Test", "Test"))
icon := []byte{0x89, 0x50, 0x4E, 0x47} // PNG header
_, handled, err := c.PERFORM(TaskSetTrayIcon{Data: icon})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetTrayMenu_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
items := []TrayMenuItem{
{Label: "Open", ActionID: "open"},
{Type: "separator"},
{Label: "Quit", ActionID: "quit"},
}
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: items})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetTrayIcon_Bad(t *testing.T) {
// No systray service — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())
require.NoError(t, err)
_, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
assert.False(t, handled)
}