From 9dae67407a796d0949e25ef04a329f1c9c4f10e3 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:18:21 +0000 Subject: [PATCH] feat(notification): add notification core.Service with fallback to dialog via IPC Co-Authored-By: Claude Opus 4.6 --- pkg/notification/messages.go | 14 ++++ pkg/notification/platform.go | 32 +++++++++ pkg/notification/service.go | 107 +++++++++++++++++++++++++++ pkg/notification/service_test.go | 119 +++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 pkg/notification/messages.go create mode 100644 pkg/notification/platform.go create mode 100644 pkg/notification/service.go create mode 100644 pkg/notification/service_test.go diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go new file mode 100644 index 0000000..e0df1ea --- /dev/null +++ b/pkg/notification/messages.go @@ -0,0 +1,14 @@ +// pkg/notification/messages.go +package notification + +// QueryPermission checks notification authorisation. Result: PermissionStatus +type QueryPermission struct{} + +// TaskSend sends a notification. Falls back to dialog if platform fails. +type TaskSend struct{ Opts NotificationOptions } + +// TaskRequestPermission requests notification authorisation. Result: bool (granted) +type TaskRequestPermission struct{} + +// ActionNotificationClicked is broadcast when a notification is clicked (future). +type ActionNotificationClicked struct{ ID string } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go new file mode 100644 index 0000000..f0d9963 --- /dev/null +++ b/pkg/notification/platform.go @@ -0,0 +1,32 @@ +// pkg/notification/platform.go +package notification + +// Platform abstracts the native notification backend. +type Platform interface { + Send(opts NotificationOptions) error + RequestPermission() (bool, error) + CheckPermission() (bool, error) +} + +// NotificationSeverity indicates the severity for dialog fallback. +type NotificationSeverity int + +const ( + SeverityInfo NotificationSeverity = iota + SeverityWarning + SeverityError +) + +// NotificationOptions contains options for sending a notification. +type NotificationOptions struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Severity NotificationSeverity `json:"severity,omitempty"` +} + +// PermissionStatus indicates whether notifications are authorised. +type PermissionStatus struct { + Granted bool `json:"granted"` +} diff --git a/pkg/notification/service.go b/pkg/notification/service.go new file mode 100644 index 0000000..df43b6d --- /dev/null +++ b/pkg/notification/service.go @@ -0,0 +1,107 @@ +// pkg/notification/service.go +package notification + +import ( + "context" + "fmt" + "time" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/dialog" +) + +// Options holds configuration for the notification service. +type Options struct{} + +// Service is a core.Service managing notifications via IPC. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// 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. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + 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 QueryPermission: + granted, err := s.platform.CheckPermission() + return PermissionStatus{Granted: granted}, true, err + default: + return nil, false, nil + } +} + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskSend: + return nil, true, s.send(t.Opts) + case TaskRequestPermission: + granted, err := s.platform.RequestPermission() + return granted, true, err + default: + return nil, false, nil + } +} + +// send attempts native notification, falls back to dialog via IPC. +func (s *Service) send(opts NotificationOptions) error { + // Generate ID if not provided + if opts.ID == "" { + opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano()) + } + + if err := s.platform.Send(opts); err != nil { + // Fallback: show as dialog via IPC + return s.fallbackDialog(opts) + } + return nil +} + +// fallbackDialog shows a dialog via IPC when native notifications fail. +func (s *Service) fallbackDialog(opts NotificationOptions) error { + // Map severity to dialog type + var dt dialog.DialogType + switch opts.Severity { + case SeverityWarning: + dt = dialog.DialogWarning + case SeverityError: + dt = dialog.DialogError + default: + dt = dialog.DialogInfo + } + + msg := opts.Message + if opts.Subtitle != "" { + msg = opts.Subtitle + "\n\n" + msg + } + + _, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{ + Opts: dialog.MessageDialogOptions{ + Type: dt, + Title: opts.Title, + Message: msg, + Buttons: []string{"OK"}, + }, + }) + return err +} diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go new file mode 100644 index 0000000..33db648 --- /dev/null +++ b/pkg/notification/service_test.go @@ -0,0 +1,119 @@ +// pkg/notification/service_test.go +package notification + +import ( + "context" + "errors" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/dialog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + sendErr error + permGranted bool + permErr error + lastOpts NotificationOptions + sendCalled bool +} + +func (m *mockPlatform) Send(opts NotificationOptions) error { + m.sendCalled = true + m.lastOpts = opts + return m.sendErr +} +func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } +func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } + +// mockDialogPlatform tracks whether MessageDialog was called (for fallback test). +type mockDialogPlatform struct { + messageCalled bool + lastMsgOpts dialog.MessageDialogOptions +} + +func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { return nil, nil } +func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { return "", nil } +func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) { + return "", nil +} +func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) { + m.messageCalled = true + m.lastMsgOpts = opts + return "OK", nil +} + +func newTestService(t *testing.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{permGranted: true} + 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, "notification") + assert.NotNil(t, svc) +} + +func TestTaskSend_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSend{ + Opts: NotificationOptions{Title: "Test", Message: "Hello"}, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.sendCalled) + assert.Equal(t, "Test", mock.lastOpts.Title) +} + +func TestTaskSend_Fallback_Good(t *testing.T) { + // Platform fails -> falls back to dialog via IPC + mockNotify := &mockPlatform{sendErr: errors.New("no permission")} + mockDlg := &mockDialogPlatform{} + c, err := core.New( + core.WithService(dialog.Register(mockDlg)), + core.WithService(Register(mockNotify)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + _, handled, err := c.PERFORM(TaskSend{ + Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning}, + }) + assert.True(t, handled) + assert.NoError(t, err) // fallback succeeds even though platform failed + assert.True(t, mockDlg.messageCalled) + assert.Equal(t, dialog.DialogWarning, mockDlg.lastMsgOpts.Type) +} + +func TestQueryPermission_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryPermission{}) + require.NoError(t, err) + assert.True(t, handled) + status := result.(PermissionStatus) + assert.True(t, status.Granted) +} + +func TestTaskRequestPermission_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.PERFORM(TaskRequestPermission{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, true, result) +} + +func TestTaskSend_Bad(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskSend{}) + assert.False(t, handled) +}