From 073794aed0c9e92338d56e857751f84d10c6b60e Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:40:41 +0000 Subject: [PATCH] feat(dock): add dock/badge core.Service with Platform interface and IPC Co-Authored-By: Claude Opus 4.6 --- pkg/dock/messages.go | 29 ++++++ pkg/dock/platform.go | 14 +++ pkg/dock/register.go | 15 +++ pkg/dock/service.go | 72 +++++++++++++++ pkg/dock/service_test.go | 194 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 pkg/dock/messages.go create mode 100644 pkg/dock/platform.go create mode 100644 pkg/dock/register.go create mode 100644 pkg/dock/service.go create mode 100644 pkg/dock/service_test.go diff --git a/pkg/dock/messages.go b/pkg/dock/messages.go new file mode 100644 index 0000000..f45cc31 --- /dev/null +++ b/pkg/dock/messages.go @@ -0,0 +1,29 @@ +// pkg/dock/messages.go +package dock + +// --- Queries (read-only) --- + +// QueryVisible returns whether the dock icon is visible. Result: bool +type QueryVisible struct{} + +// --- Tasks (side-effects) --- + +// TaskShowIcon shows the dock/taskbar icon. Result: nil +type TaskShowIcon struct{} + +// TaskHideIcon hides the dock/taskbar icon. Result: nil +type TaskHideIcon struct{} + +// TaskSetBadge sets the dock/taskbar badge label. +// Empty string "" shows the default system badge indicator. +// Numeric "3", "99" shows unread count. Text "New", "Paused" shows brief status. +// Result: nil +type TaskSetBadge struct{ Label string } + +// TaskRemoveBadge removes the dock/taskbar badge. Result: nil +type TaskRemoveBadge struct{} + +// --- Actions (broadcasts) --- + +// ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. +type ActionVisibilityChanged struct{ Visible bool } diff --git a/pkg/dock/platform.go b/pkg/dock/platform.go new file mode 100644 index 0000000..d34004a --- /dev/null +++ b/pkg/dock/platform.go @@ -0,0 +1,14 @@ +// pkg/dock/platform.go +package dock + +// Platform abstracts the dock/taskbar backend (Wails v3). +// macOS: dock icon show/hide + badge. +// Windows: taskbar badge only (show/hide not supported). +// Linux: not supported — adapter returns nil for all operations. +type Platform interface { + ShowIcon() error + HideIcon() error + SetBadge(label string) error + RemoveBadge() error + IsVisible() bool +} diff --git a/pkg/dock/register.go b/pkg/dock/register.go new file mode 100644 index 0000000..1123927 --- /dev/null +++ b/pkg/dock/register.go @@ -0,0 +1,15 @@ +// pkg/dock/register.go +package dock + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +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, + }, nil + } +} diff --git a/pkg/dock/service.go b/pkg/dock/service.go new file mode 100644 index 0000000..260ff0a --- /dev/null +++ b/pkg/dock/service.go @@ -0,0 +1,72 @@ +// pkg/dock/service.go +package dock + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the dock service. +type Options struct{} + +// Service is a core.Service managing dock/taskbar operations via IPC. +// It embeds ServiceRuntime for Core access and delegates to Platform. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q.(type) { + case QueryVisible: + return s.platform.IsVisible(), true, nil + default: + return nil, false, nil + } +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskShowIcon: + if err := s.platform.ShowIcon(); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionVisibilityChanged{Visible: true}) + return nil, true, nil + case TaskHideIcon: + if err := s.platform.HideIcon(); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionVisibilityChanged{Visible: false}) + return nil, true, nil + case TaskSetBadge: + if err := s.platform.SetBadge(t.Label); err != nil { + return nil, true, err + } + return nil, true, nil + case TaskRemoveBadge: + if err := s.platform.RemoveBadge(); err != nil { + return nil, true, err + } + return nil, true, nil + default: + return nil, false, nil + } +} diff --git a/pkg/dock/service_test.go b/pkg/dock/service_test.go new file mode 100644 index 0000000..503e870 --- /dev/null +++ b/pkg/dock/service_test.go @@ -0,0 +1,194 @@ +// pkg/dock/service_test.go +package dock + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock Platform --- + +type mockPlatform struct { + visible bool + badge string + hasBadge bool + showErr error + hideErr error + badgeErr error + removeErr error +} + +func (m *mockPlatform) ShowIcon() error { + if m.showErr != nil { + return m.showErr + } + m.visible = true + return nil +} + +func (m *mockPlatform) HideIcon() error { + if m.hideErr != nil { + return m.hideErr + } + m.visible = false + return nil +} + +func (m *mockPlatform) SetBadge(label string) error { + if m.badgeErr != nil { + return m.badgeErr + } + m.badge = label + m.hasBadge = true + return nil +} + +func (m *mockPlatform) RemoveBadge() error { + if m.removeErr != nil { + return m.removeErr + } + m.badge = "" + m.hasBadge = false + return nil +} + +func (m *mockPlatform) IsVisible() bool { return m.visible } + +// --- Test helpers --- + +func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := &mockPlatform{visible: true} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "dock") + return svc, c, mock +} + +// --- Tests --- + +func TestRegister_Good(t *testing.T) { + svc, _, _ := newTestDockService(t) + assert.NotNil(t, svc) +} + +func TestQueryVisible_Good(t *testing.T) { + _, c, _ := newTestDockService(t) + result, handled, err := c.QUERY(QueryVisible{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, true, result) +} + +func TestQueryVisible_Bad(t *testing.T) { + // No dock service registered — QUERY returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.QUERY(QueryVisible{}) + assert.False(t, handled) +} + +func TestTaskShowIcon_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.visible = false // Start hidden + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskShowIcon{}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.visible) + require.NotNil(t, received) + assert.True(t, received.Visible) +} + +func TestTaskHideIcon_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.visible = true // Start visible + + var received *ActionVisibilityChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionVisibilityChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskHideIcon{}) + require.NoError(t, err) + assert.True(t, handled) + assert.False(t, mock.visible) + require.NotNil(t, received) + assert.False(t, received.Visible) +} + +func TestTaskSetBadge_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "3", mock.badge) + assert.True(t, mock.hasBadge) +} + +func TestTaskSetBadge_EmptyLabel_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + _, handled, err := c.PERFORM(TaskSetBadge{Label: ""}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "", mock.badge) + assert.True(t, mock.hasBadge) // Empty string = default system badge indicator +} + +func TestTaskRemoveBadge_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + // Set a badge first + _, _, _ = c.PERFORM(TaskSetBadge{Label: "5"}) + + _, handled, err := c.PERFORM(TaskRemoveBadge{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "", mock.badge) + assert.False(t, mock.hasBadge) +} + +func TestTaskShowIcon_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.showErr = assert.AnError + + _, handled, err := c.PERFORM(TaskShowIcon{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskHideIcon_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.hideErr = assert.AnError + + _, handled, err := c.PERFORM(TaskHideIcon{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskSetBadge_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.badgeErr = assert.AnError + + _, handled, err := c.PERFORM(TaskSetBadge{Label: "3"}) + assert.True(t, handled) + assert.Error(t, err) +}