feat(dock): add dock/badge core.Service with Platform interface and IPC

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:40:41 +00:00
parent 22315d07bb
commit 073794aed0
5 changed files with 324 additions and 0 deletions

29
pkg/dock/messages.go Normal file
View file

@ -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 }

14
pkg/dock/platform.go Normal file
View file

@ -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
}

15
pkg/dock/register.go Normal file
View file

@ -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
}
}

72
pkg/dock/service.go Normal file
View file

@ -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
}
}

194
pkg/dock/service_test.go Normal file
View file

@ -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)
}