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:
parent
a59028f112
commit
56ef6f3928
4 changed files with 185 additions and 0 deletions
30
pkg/systray/messages.go
Normal file
30
pkg/systray/messages.go
Normal 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
14
pkg/systray/register.go
Normal 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
78
pkg/systray/service.go
Normal 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
|
||||
}
|
||||
63
pkg/systray/service_test.go
Normal file
63
pkg/systray/service_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue