diff --git a/internal/api/app.go b/internal/api/app.go index e725444..5ffeac1 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -39,10 +39,15 @@ import ( var newSyncClient = syncsvc.NewClient var emitFrontendEvent = runtime.EventsEmit +var initializeNativeNotifications = runtime.InitializeNotifications +var cleanupNativeNotifications = runtime.CleanupNotifications +var sendNativeNotification = runtime.SendNotification type notificationService interface { Replace(pluginID string, requests []notifications.Request) error Clear(pluginID string) error + Start(ctx context.Context) + Stop() } const pluginEventRuntimeName = "verstak:plugin-event" @@ -192,6 +197,43 @@ func (a *App) Startup(ctx context.Context) { log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) } +// DomReady initializes the native notification runtime before starting schedules. +func (a *App) DomReady(ctx context.Context) { + if a.notifications == nil { + return + } + if err := initializeNativeNotifications(ctx); err != nil { + log.Printf("[api] native notifications unavailable: %v", err) + return + } + a.notifications.Start(ctx) +} + +// Shutdown stops scheduled delivery before releasing native notification resources. +func (a *App) Shutdown(ctx context.Context) { + if a.notifications != nil { + a.notifications.Stop() + } + cleanupNativeNotifications(ctx) +} + +// NativeNotificationSender delivers scheduler items through the Wails runtime. +type NativeNotificationSender struct{} + +// NewNativeNotificationSender creates the adapter used by the core scheduler. +func NewNativeNotificationSender() notifications.Sender { + return NativeNotificationSender{} +} + +// Send shows a native system notification for a scheduled plugin reminder. +func (NativeNotificationSender) Send(ctx context.Context, item notifications.Item) error { + return sendNativeNotification(ctx, runtime.NotificationOptions{ + ID: "verstak:" + item.PluginID + ":" + item.ID, + Title: item.Title, + Body: item.Body, + }) +} + func (a *App) ensureBrowserInboxSubscriptions() { if a.eventBus == nil || a.storage == nil { a.browserInboxEnabled.Store(false) diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 08922c0..ce6ffbe 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/wailsapp/wails/v2/pkg/runtime" + "github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/browserreceiver" "github.com/verstak/verstak-desktop/internal/core/capability" @@ -31,8 +33,12 @@ import ( type fakeNotificationScheduler struct { replaceCalls int clearCalls int + startCalls int + stopCalls int pluginID string requests []notifications.Request + onStart func() + onStop func() } func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error { @@ -48,6 +54,20 @@ func (s *fakeNotificationScheduler) Clear(pluginID string) error { return nil } +func (s *fakeNotificationScheduler) Start(context.Context) { + s.startCalls++ + if s.onStart != nil { + s.onStart() + } +} + +func (s *fakeNotificationScheduler) Stop() { + s.stopCalls++ + if s.onStop != nil { + s.onStop() + } +} + func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server { t.Helper() @@ -205,6 +225,87 @@ func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing. } } +func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) { + oldInitialize := initializeNativeNotifications + defer func() { initializeNativeNotifications = oldInitialize }() + + order := []string{} + initializeNativeNotifications = func(context.Context) error { + order = append(order, "initialize") + return nil + } + app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"}) + scheduler.onStart = func() { order = append(order, "start") } + + app.DomReady(context.Background()) + + if got, want := strings.Join(order, ","), "initialize,start"; got != want { + t.Fatalf("notification startup order = %q, want %q", got, want) + } + if scheduler.startCalls != 1 { + t.Fatalf("scheduler start calls = %d, want 1", scheduler.startCalls) + } +} + +func TestDomReadyDoesNotStartSchedulerWhenNotificationInitializationFails(t *testing.T) { + oldInitialize := initializeNativeNotifications + defer func() { initializeNativeNotifications = oldInitialize }() + + initializeNativeNotifications = func(context.Context) error { return fmt.Errorf("unavailable") } + app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"}) + + app.DomReady(context.Background()) + + if scheduler.startCalls != 0 { + t.Fatalf("scheduler start calls = %d, want 0 when native notifications are unavailable", scheduler.startCalls) + } +} + +func TestShutdownStopsSchedulerBeforeCleaningUpNotifications(t *testing.T) { + oldCleanup := cleanupNativeNotifications + defer func() { cleanupNativeNotifications = oldCleanup }() + + order := []string{} + cleanupNativeNotifications = func(context.Context) { order = append(order, "cleanup") } + app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"}) + scheduler.onStop = func() { order = append(order, "stop") } + + app.Shutdown(context.Background()) + + if got, want := strings.Join(order, ","), "stop,cleanup"; got != want { + t.Fatalf("notification shutdown order = %q, want %q", got, want) + } + if scheduler.stopCalls != 1 { + t.Fatalf("scheduler stop calls = %d, want 1", scheduler.stopCalls) + } +} + +func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) { + oldSend := sendNativeNotification + defer func() { sendNativeNotification = oldSend }() + + var gotTitle, gotBody, gotID string + sendNativeNotification = func(_ context.Context, options runtime.NotificationOptions) error { + gotID = options.ID + gotTitle = options.Title + gotBody = options.Body + return nil + } + + err := NewNativeNotificationSender().Send(context.Background(), notifications.Item{ + PluginID: "verstak.todo", + ID: "task-42", + Title: "Todo reminder", + Body: "Prepare alpha release", + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if gotID != "verstak:verstak.todo:task-42" || gotTitle != "Todo reminder" || gotBody != "Prepare alpha release" { + t.Fatalf("native notification = id=%q title=%q body=%q", gotID, gotTitle, gotBody) + } +} + // TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that // GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { diff --git a/main.go b/main.go index 80eb867..8464ba8 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "embed" "log" "os" + "time" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" @@ -16,6 +17,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/events" corefiles "github.com/verstak/verstak-desktop/internal/core/files" + "github.com/verstak/verstak-desktop/internal/core/notifications" "github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" @@ -91,6 +93,7 @@ func main() { "verstak/core/events/v1", "verstak/core/files/v1", "verstak/core/workbench/v1", + "verstak/core/notifications/v1", } if err := capRegistry.Register(corePluginID, coreCaps); err != nil { log.Fatalf("[main] failed to register core capabilities: %v", err) @@ -232,6 +235,7 @@ func main() { }) } app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) + app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now)) if browserReceiver != nil { browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) if err != nil { @@ -251,6 +255,8 @@ func main() { MinHeight: 600, WindowStartState: options.Normal, OnStartup: app.Startup, + OnDomReady: app.DomReady, + OnShutdown: app.Shutdown, AssetServer: &assetserver.Options{ Assets: assets, },