feat(notification): add notification core.Service with fallback to dialog via IPC

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:18:21 +00:00
parent df37dfa041
commit 9dae67407a
4 changed files with 272 additions and 0 deletions

View file

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

View file

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

107
pkg/notification/service.go Normal file
View file

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

View file

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