// pkg/notification/service.go package notification import ( "context" "strconv" "sync" "time" core "dappco.re/go/core" coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/gui/pkg/dialog" ) type Options struct{} type Service struct { *core.ServiceRuntime[Options] platform Platform categories map[string]NotificationCategory mu sync.Mutex active map[string]NotificationOptions } func Register(p Platform) func(*core.Core) core.Result { return func(c *core.Core) core.Result { return core.Result{Value: &Service{ ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), platform: p, categories: make(map[string]NotificationCategory), active: make(map[string]NotificationOptions), }, OK: true} } } func (s *Service) OnStartup(_ context.Context) core.Result { s.Core().RegisterQuery(s.handleQuery) send := func(_ context.Context, opts core.Options) core.Result { options, err := notificationOptionsFrom(opts) if err != nil { return core.Result{Value: err, OK: false} } return core.Result{Value: nil, OK: true}.New(s.send(options)) } s.Core().Action("notification.requestPermission", func(_ context.Context, _ core.Options) core.Result { granted, err := s.platform.RequestPermission() return core.Result{}.New(granted, err) }) s.Core().Action("notification.revokePermission", func(_ context.Context, _ core.Options) core.Result { return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission()) }) s.Core().Action("notification.registerCategory", func(_ context.Context, opts core.Options) core.Result { t, _ := opts.Get("task").Value.(TaskRegisterCategory) s.categories[t.Category.ID] = t.Category return core.Result{OK: true} }) s.Core().Action("notification.clear", func(_ context.Context, opts core.Options) core.Result { t, _ := opts.Get("task").Value.(TaskClear) return core.Result{Value: nil, OK: true}.New(s.clear(t.ID)) }) s.Core().Action("notification.send", send) s.Core().Action("gui.notification.send", send) return core.Result{OK: true} } func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { return core.Result{OK: true} } func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { switch q.(type) { case QueryPermission: granted, err := s.platform.CheckPermission() if err != nil { return core.Result{Value: err, OK: false} } return core.Result{Value: PermissionStatus{Granted: granted}, OK: true} default: return core.Result{} } } // send attempts native notification, falls back to dialog via IPC. func (s *Service) send(options NotificationOptions) error { // Generate ID if not provided if options.ID == "" { options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) } if err := s.platform.Send(options); err != nil { // Fallback: show as dialog via IPC if err := s.fallbackDialog(options); err != nil { return err } } s.mu.Lock() s.active[options.ID] = options s.mu.Unlock() return nil } // fallbackDialog shows a dialog via IPC when native notifications fail. func (s *Service) fallbackDialog(options NotificationOptions) error { // Map severity to dialog type var dt dialog.DialogType switch options.Severity { case SeverityWarning: dt = dialog.DialogWarning case SeverityError: dt = dialog.DialogError default: dt = dialog.DialogInfo } message := options.Message if options.Subtitle != "" { message = options.Subtitle + "\n\n" + message } r := s.Core().Action("dialog.message").Run(context.Background(), core.NewOptions( core.Option{Key: "task", Value: dialog.TaskMessageDialog{ Options: dialog.MessageDialogOptions{ Type: dt, Title: options.Title, Message: message, Buttons: []string{"OK"}, }, }}, )) if !r.OK { if err, ok := r.Value.(error); ok { return err } } return nil } func (s *Service) clear(id string) error { if clearer, ok := s.platform.(ClearPlatform); ok { if err := clearer.Clear(id); err != nil { return err } } ids := s.removeActive(id) for _, notificationID := range ids { _ = s.Core().ACTION(ActionNotificationDismissed{ID: notificationID}) } return nil } func (s *Service) removeActive(id string) []string { s.mu.Lock() defer s.mu.Unlock() if id != "" { if _, ok := s.active[id]; !ok { return nil } delete(s.active, id) return []string{id} } ids := make([]string, 0, len(s.active)) for notificationID := range s.active { ids = append(ids, notificationID) } clear(s.active) return ids } func notificationOptionsFrom(opts core.Options) (NotificationOptions, error) { if task := opts.Get("task"); task.OK { switch v := task.Value.(type) { case TaskSend: return v.Options, nil case NotificationOptions: return v, nil } } return decodeOptions[NotificationOptions](opts) } func decodeOptions[T any](opts core.Options) (T, error) { var input T items := make(map[string]any, opts.Len()) for _, item := range opts.Items() { items[item.Key] = item.Value } if len(items) == 0 { return input, nil } result := core.JSONUnmarshalString(core.JSONMarshalString(items), &input) if !result.OK { if err, ok := result.Value.(error); ok { return input, err } return input, coreerr.E("notification.decodeOptions", "failed to decode notification options", nil) } return input, nil }