diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go new file mode 100644 index 0000000..8813dc1 --- /dev/null +++ b/pkg/environment/messages.go @@ -0,0 +1,22 @@ +// pkg/environment/messages.go +package environment + +// QueryTheme returns the current theme. Result: ThemeInfo +type QueryTheme struct{} + +// QueryInfo returns environment information. Result: EnvironmentInfo +type QueryInfo struct{} + +// QueryAccentColour returns the system accent colour. Result: string +type QueryAccentColour struct{} + +// TaskOpenFileManager opens the system file manager. Result: error only +type TaskOpenFileManager struct { + Path string `json:"path"` + Select bool `json:"select"` +} + +// ActionThemeChanged is broadcast when the system theme changes. +type ActionThemeChanged struct { + IsDark bool `json:"isDark"` +} diff --git a/pkg/environment/platform.go b/pkg/environment/platform.go new file mode 100644 index 0000000..3e403f9 --- /dev/null +++ b/pkg/environment/platform.go @@ -0,0 +1,31 @@ +// pkg/environment/platform.go +package environment + +// Platform abstracts environment and theme backend queries. +type Platform interface { + IsDarkMode() bool + Info() EnvironmentInfo + AccentColour() string + OpenFileManager(path string, selectFile bool) error + OnThemeChange(handler func(isDark bool)) func() // returns cancel func +} + +// EnvironmentInfo contains system environment details. +type EnvironmentInfo struct { + OS string `json:"os"` + Arch string `json:"arch"` + Debug bool `json:"debug"` + Platform PlatformInfo `json:"platform"` +} + +// PlatformInfo contains platform-specific details. +type PlatformInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// ThemeInfo contains the current theme state. +type ThemeInfo struct { + IsDark bool `json:"isDark"` + Theme string `json:"theme"` // "dark" or "light" +} diff --git a/pkg/environment/service.go b/pkg/environment/service.go new file mode 100644 index 0000000..7b160d9 --- /dev/null +++ b/pkg/environment/service.go @@ -0,0 +1,80 @@ +// pkg/environment/service.go +package environment + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the environment service. +type Options struct{} + +// Service is a core.Service providing environment queries and theme change events via IPC. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + cancelTheme func() // cancel function for theme change listener +} + +// 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, + }, nil + } +} + +// OnStartup registers IPC handlers and the theme change listener. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + + // Register theme change callback — broadcasts ActionThemeChanged via IPC + s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { + _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) + }) + return nil +} + +// OnShutdown cancels the theme change listener. +func (s *Service) OnShutdown(ctx context.Context) error { + if s.cancelTheme != nil { + s.cancelTheme() + } + return nil +} + +// HandleIPCEvents is auto-discovered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q.(type) { + case QueryTheme: + isDark := s.platform.IsDarkMode() + theme := "light" + if isDark { + theme = "dark" + } + return ThemeInfo{IsDark: isDark, Theme: theme}, true, nil + case QueryInfo: + return s.platform.Info(), true, nil + case QueryAccentColour: + return s.platform.AccentColour(), true, nil + default: + return nil, false, nil + } +} + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskOpenFileManager: + return nil, true, s.platform.OpenFileManager(t.Path, t.Select) + default: + return nil, false, nil + } +} diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go new file mode 100644 index 0000000..76ec531 --- /dev/null +++ b/pkg/environment/service_test.go @@ -0,0 +1,133 @@ +// pkg/environment/service_test.go +package environment + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + isDark bool + info EnvironmentInfo + accentColour string + openFMErr error + themeHandler func(isDark bool) + mu sync.Mutex +} + +func (m *mockPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockPlatform) Info() EnvironmentInfo { return m.info } +func (m *mockPlatform) AccentColour() string { return m.accentColour } +func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { + return m.openFMErr +} +func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() { + m.mu.Lock() + m.themeHandler = handler + m.mu.Unlock() + return func() { + m.mu.Lock() + m.themeHandler = nil + m.mu.Unlock() + } +} + +// simulateThemeChange triggers the stored handler (test helper). +func (m *mockPlatform) simulateThemeChange(isDark bool) { + m.mu.Lock() + h := m.themeHandler + m.mu.Unlock() + if h != nil { + h(isDark) + } +} + +func newTestService(t *testing.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + isDark: true, + accentColour: "rgb(0,122,255)", + info: EnvironmentInfo{ + OS: "darwin", Arch: "arm64", + Platform: PlatformInfo{Name: "macOS", Version: "14.0"}, + }, + } + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + return mock, c +} + +func TestRegister_Good(t *testing.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "environment") + assert.NotNil(t, svc) +} + +func TestQueryTheme_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.True(t, theme.IsDark) + assert.Equal(t, "dark", theme.Theme) +} + +func TestQueryInfo_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryInfo{}) + require.NoError(t, err) + assert.True(t, handled) + info := result.(EnvironmentInfo) + assert.Equal(t, "darwin", info.OS) + assert.Equal(t, "arm64", info.Arch) +} + +func TestQueryAccentColour_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryAccentColour{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "rgb(0,122,255)", result) +} + +func TestTaskOpenFileManager_Good(t *testing.T) { + _, c := newTestService(t) + _, handled, err := c.PERFORM(TaskOpenFileManager{Path: "/tmp", Select: true}) + require.NoError(t, err) + assert.True(t, handled) +} + +func TestThemeChange_ActionBroadcast_Good(t *testing.T) { + mock, c := newTestService(t) + + // Register a listener that captures the action + var received *ActionThemeChanged + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionThemeChanged); ok { + mu.Lock() + received = &a + mu.Unlock() + } + return nil + }) + + // Simulate theme change + mock.simulateThemeChange(false) + + mu.Lock() + r := received + mu.Unlock() + require.NotNil(t, r) + assert.False(t, r.IsDark) +}