feat: run scheduled native notifications
This commit is contained in:
parent
96dd499e0b
commit
e33c63d3ad
|
|
@ -39,10 +39,15 @@ import (
|
||||||
|
|
||||||
var newSyncClient = syncsvc.NewClient
|
var newSyncClient = syncsvc.NewClient
|
||||||
var emitFrontendEvent = runtime.EventsEmit
|
var emitFrontendEvent = runtime.EventsEmit
|
||||||
|
var initializeNativeNotifications = runtime.InitializeNotifications
|
||||||
|
var cleanupNativeNotifications = runtime.CleanupNotifications
|
||||||
|
var sendNativeNotification = runtime.SendNotification
|
||||||
|
|
||||||
type notificationService interface {
|
type notificationService interface {
|
||||||
Replace(pluginID string, requests []notifications.Request) error
|
Replace(pluginID string, requests []notifications.Request) error
|
||||||
Clear(pluginID string) error
|
Clear(pluginID string) error
|
||||||
|
Start(ctx context.Context)
|
||||||
|
Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
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))
|
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() {
|
func (a *App) ensureBrowserInboxSubscriptions() {
|
||||||
if a.eventBus == nil || a.storage == nil {
|
if a.eventBus == nil || a.storage == nil {
|
||||||
a.browserInboxEnabled.Store(false)
|
a.browserInboxEnabled.Store(false)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
"github.com/verstak/verstak-desktop/internal/core/appsettings"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
|
"github.com/verstak/verstak-desktop/internal/core/browserreceiver"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/capability"
|
"github.com/verstak/verstak-desktop/internal/core/capability"
|
||||||
|
|
@ -31,8 +33,12 @@ import (
|
||||||
type fakeNotificationScheduler struct {
|
type fakeNotificationScheduler struct {
|
||||||
replaceCalls int
|
replaceCalls int
|
||||||
clearCalls int
|
clearCalls int
|
||||||
|
startCalls int
|
||||||
|
stopCalls int
|
||||||
pluginID string
|
pluginID string
|
||||||
requests []notifications.Request
|
requests []notifications.Request
|
||||||
|
onStart func()
|
||||||
|
onStop func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error {
|
func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error {
|
||||||
|
|
@ -48,6 +54,20 @@ func (s *fakeNotificationScheduler) Clear(pluginID string) error {
|
||||||
return nil
|
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 {
|
func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||||
t.Helper()
|
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
|
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
|
||||||
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
|
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
|
||||||
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
|
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"embed"
|
"embed"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"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/contribution"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||||
corefiles "github.com/verstak/verstak-desktop/internal/core/files"
|
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/permissions"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
|
|
@ -91,6 +93,7 @@ func main() {
|
||||||
"verstak/core/events/v1",
|
"verstak/core/events/v1",
|
||||||
"verstak/core/files/v1",
|
"verstak/core/files/v1",
|
||||||
"verstak/core/workbench/v1",
|
"verstak/core/workbench/v1",
|
||||||
|
"verstak/core/notifications/v1",
|
||||||
}
|
}
|
||||||
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
|
||||||
log.Fatalf("[main] failed to register core capabilities: %v", err)
|
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 = 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 {
|
if browserReceiver != nil {
|
||||||
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -251,6 +255,8 @@ func main() {
|
||||||
MinHeight: 600,
|
MinHeight: 600,
|
||||||
WindowStartState: options.Normal,
|
WindowStartState: options.Normal,
|
||||||
OnStartup: app.Startup,
|
OnStartup: app.Startup,
|
||||||
|
OnDomReady: app.DomReady,
|
||||||
|
OnShutdown: app.Shutdown,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue