diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index 3576d0f..9c255bc 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -220,6 +220,24 @@ export function createPluginAPI(pluginId) { } }, + notifications: { + replace: function(items) { + assertActive('notifications.replace'); + if (!Array.isArray(items)) { + throw new Error('notifications.replace requires an array'); + } + return callBackendErrorString(pluginId, 'notifications.replace', function() { + return App.ReplacePluginNotifications(pluginId, items); + }); + }, + clear: function() { + assertActive('notifications.clear'); + return callBackendErrorString(pluginId, 'notifications.clear', function() { + return App.ClearPluginNotifications(pluginId); + }); + } + }, + settings: { read: async function(key) { assertActive('settings.read'); diff --git a/frontend/tests/plugin-api-contributions-test.mjs b/frontend/tests/plugin-api-contributions-test.mjs index 0e07247..992a99e 100644 --- a/frontend/tests/plugin-api-contributions-test.mjs +++ b/frontend/tests/plugin-api-contributions-test.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; const pluginData = {}; +const scheduledNotifications = []; globalThis.window = { __VERSTAK_PLUGIN_REGISTRY__: {}, @@ -38,6 +39,14 @@ globalThis.window = { pluginData[pluginId][name] = Object.assign({}, data || {}); return Promise.resolve(''); }, + ReplacePluginNotifications: (pluginId, items) => { + scheduledNotifications.push({ pluginId, items }); + return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : ''); + }, + ClearPluginNotifications: (pluginId) => { + scheduledNotifications.push({ pluginId, clear: true }); + return Promise.resolve(''); + }, }, }, }, @@ -116,6 +125,28 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') { throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`); } +if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') { + throw new Error('api.notifications replace/clear contract is missing'); +} +await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]); +await api.notifications.clear(); +if (scheduledNotifications.length !== 2 + || scheduledNotifications[0].pluginId !== 'verstak.files' + || scheduledNotifications[0].items[0].id !== 'reminder-1' + || scheduledNotifications[1].pluginId !== 'verstak.files' + || !scheduledNotifications[1].clear) { + throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`); +} +let rejected = false; +try { + await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]); +} catch (err) { + rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied'); +} +if (!rejected) { + throw new Error('notification backend errors must be plugin-scoped rejections'); +} + api.dispose(); if (localeListeners.size !== 0) { throw new Error('api.i18n locale subscription was not disposed'); diff --git a/internal/api/app.go b/internal/api/app.go index 184ad16..e725444 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -24,6 +24,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/externalopen" corefiles "github.com/verstak/verstak-desktop/internal/core/files" "github.com/verstak/verstak-desktop/internal/core/filewatcher" + "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" @@ -39,6 +40,11 @@ import ( var newSyncClient = syncsvc.NewClient var emitFrontendEvent = runtime.EventsEmit +type notificationService interface { + Replace(pluginID string, requests []notifications.Request) error + Clear(pluginID string) error +} + const pluginEventRuntimeName = "verstak:plugin-event" const activityPluginID = "verstak.activity" const activityRawDataName = "activity-events" @@ -80,12 +86,21 @@ type App struct { browserReceiver *browserreceiver.Receiver secretsSession *coresecrets.VaultSession fileWatcher *filewatcher.Service + notifications notificationService debug bool activityEvents map[string]bool browserInboxEvents map[string]bool browserInboxEnabled atomic.Bool } +// SetNotificationService attaches the core-owned plugin notification scheduler. +func (a *App) SetNotificationService(service notificationService) { + if a == nil { + return + } + a.notifications = service +} + type externalOpenService interface { OpenPath(path string) error ShowInFolder(path string, isDir bool) error @@ -1266,6 +1281,44 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa return "" } +// ReplacePluginNotifications replaces one plugin's desired native notification +// schedules. Plugins must declare both the capability and permission. +func (a *App) ReplacePluginNotifications(pluginID string, requests []notifications.Request) string { + if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil { + return err.Error() + } + if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil { + return err.Error() + } + if a.notifications == nil { + return "notification scheduler not initialized" + } + if err := a.notifications.Replace(pluginID, requests); err != nil { + log.Printf("[api] ReplacePluginNotifications(%s): %v", pluginID, err) + return err.Error() + } + return "" +} + +// ClearPluginNotifications removes every native notification schedule owned by +// one plugin. +func (a *App) ClearPluginNotifications(pluginID string) string { + if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil { + return err.Error() + } + if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil { + return err.Error() + } + if a.notifications == nil { + return "notification scheduler not initialized" + } + if err := a.notifications.Clear(pluginID); err != nil { + log.Printf("[api] ClearPluginNotifications(%s): %v", pluginID, err) + return err.Error() + } + return "" +} + // ListVaultFiles lists a vault-relative directory for a plugin with files.read. func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) { if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index ce3bade..08922c0 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -20,6 +20,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/plugin" "github.com/verstak/verstak-desktop/internal/core/storage" syncsvc "github.com/verstak/verstak-desktop/internal/core/sync" @@ -27,6 +28,26 @@ import ( "github.com/verstak/verstak-desktop/internal/core/workspace" ) +type fakeNotificationScheduler struct { + replaceCalls int + clearCalls int + pluginID string + requests []notifications.Request +} + +func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error { + s.replaceCalls++ + s.pluginID = pluginID + s.requests = append([]notifications.Request(nil), requests...) + return nil +} + +func (s *fakeNotificationScheduler) Clear(pluginID string) error { + s.clearCalls++ + s.pluginID = pluginID + return nil +} + func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server { t.Helper() @@ -77,6 +98,18 @@ func newTestApp(tmpRoot string) *App { } } +func newNotificationsTestApp(manifest plugin.Manifest) (*App, *fakeNotificationScheduler) { + scheduler := &fakeNotificationScheduler{} + return &App{ + plugins: []plugin.Plugin{{ + Manifest: manifest, + Status: plugin.StatusLoaded, + Enabled: true, + }}, + notifications: scheduler, + }, scheduler +} + func newFilesTestApp(t *testing.T, perms []string) (*App, string) { t.Helper() v := vault.NewVault(nil) @@ -134,6 +167,44 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s return app, root } +func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) { + requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}} + for _, manifest := range []plugin.Manifest{ + {ID: "notifications.test", Permissions: []string{"notifications.schedule"}}, + {ID: "notifications.test", Requires: []string{"verstak/core/notifications/v1"}}, + } { + app, scheduler := newNotificationsTestApp(manifest) + if got := app.ReplacePluginNotifications(manifest.ID, requests); got == "" { + t.Fatalf("manifest %#v unexpectedly scheduled notifications", manifest) + } + if scheduler.replaceCalls != 0 { + t.Fatalf("rejected request called scheduler %d times", scheduler.replaceCalls) + } + } +} + +func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.T) { + manifest := plugin.Manifest{ + ID: "notifications.test", + Permissions: []string{"notifications.schedule"}, + Requires: []string{"verstak/core/notifications/v1"}, + } + app, scheduler := newNotificationsTestApp(manifest) + requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}} + if got := app.ReplacePluginNotifications(manifest.ID, requests); got != "" { + t.Fatalf("ReplacePluginNotifications error = %q", got) + } + if scheduler.replaceCalls != 1 || scheduler.pluginID != manifest.ID || len(scheduler.requests) != 1 { + t.Fatalf("scheduler state = %#v", scheduler) + } + if got := app.ClearPluginNotifications(manifest.ID); got != "" { + t.Fatalf("ClearPluginNotifications error = %q", got) + } + if scheduler.clearCalls != 1 || scheduler.pluginID != manifest.ID { + t.Fatalf("clear state = %#v", scheduler) + } +} + // TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that // GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { diff --git a/internal/core/permissions/registry.go b/internal/core/permissions/registry.go index b2807f5..a28e146 100644 --- a/internal/core/permissions/registry.go +++ b/internal/core/permissions/registry.go @@ -39,6 +39,7 @@ func (r *Registry) registerDefaults() { {Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true}, {Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false}, {Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false}, + {Name: "notifications.schedule", Description: "Schedule native notifications in the plugin's own namespace", Dangerous: false}, {Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false}, {Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false}, {Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},