diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go new file mode 100644 index 0000000..4fc5bfe --- /dev/null +++ b/pkg/systray/messages.go @@ -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 } diff --git a/pkg/systray/register.go b/pkg/systray/register.go new file mode 100644 index 0000000..b4d133b --- /dev/null +++ b/pkg/systray/register.go @@ -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 + } +} diff --git a/pkg/systray/service.go b/pkg/systray/service.go new file mode 100644 index 0000000..6dc2ec9 --- /dev/null +++ b/pkg/systray/service.go @@ -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 +} diff --git a/pkg/systray/service_test.go b/pkg/systray/service_test.go new file mode 100644 index 0000000..4bcec30 --- /dev/null +++ b/pkg/systray/service_test.go @@ -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) +}