feat: expose scheduled notifications to plugins
This commit is contained in:
parent
67753927cb
commit
96dd499e0b
|
|
@ -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: {
|
settings: {
|
||||||
read: async function(key) {
|
read: async function(key) {
|
||||||
assertActive('settings.read');
|
assertActive('settings.read');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
const pluginData = {};
|
const pluginData = {};
|
||||||
|
const scheduledNotifications = [];
|
||||||
|
|
||||||
globalThis.window = {
|
globalThis.window = {
|
||||||
__VERSTAK_PLUGIN_REGISTRY__: {},
|
__VERSTAK_PLUGIN_REGISTRY__: {},
|
||||||
|
|
@ -38,6 +39,14 @@ globalThis.window = {
|
||||||
pluginData[pluginId][name] = Object.assign({}, data || {});
|
pluginData[pluginId][name] = Object.assign({}, data || {});
|
||||||
return Promise.resolve('');
|
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)}`);
|
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();
|
api.dispose();
|
||||||
if (localeListeners.size !== 0) {
|
if (localeListeners.size !== 0) {
|
||||||
throw new Error('api.i18n locale subscription was not disposed');
|
throw new Error('api.i18n locale subscription was not disposed');
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/externalopen"
|
"github.com/verstak/verstak-desktop/internal/core/externalopen"
|
||||||
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/filewatcher"
|
"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/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"
|
||||||
|
|
@ -39,6 +40,11 @@ import (
|
||||||
var newSyncClient = syncsvc.NewClient
|
var newSyncClient = syncsvc.NewClient
|
||||||
var emitFrontendEvent = runtime.EventsEmit
|
var emitFrontendEvent = runtime.EventsEmit
|
||||||
|
|
||||||
|
type notificationService interface {
|
||||||
|
Replace(pluginID string, requests []notifications.Request) error
|
||||||
|
Clear(pluginID string) error
|
||||||
|
}
|
||||||
|
|
||||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
const pluginEventRuntimeName = "verstak:plugin-event"
|
||||||
const activityPluginID = "verstak.activity"
|
const activityPluginID = "verstak.activity"
|
||||||
const activityRawDataName = "activity-events"
|
const activityRawDataName = "activity-events"
|
||||||
|
|
@ -80,12 +86,21 @@ type App struct {
|
||||||
browserReceiver *browserreceiver.Receiver
|
browserReceiver *browserreceiver.Receiver
|
||||||
secretsSession *coresecrets.VaultSession
|
secretsSession *coresecrets.VaultSession
|
||||||
fileWatcher *filewatcher.Service
|
fileWatcher *filewatcher.Service
|
||||||
|
notifications notificationService
|
||||||
debug bool
|
debug bool
|
||||||
activityEvents map[string]bool
|
activityEvents map[string]bool
|
||||||
browserInboxEvents map[string]bool
|
browserInboxEvents map[string]bool
|
||||||
browserInboxEnabled atomic.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 {
|
type externalOpenService interface {
|
||||||
OpenPath(path string) error
|
OpenPath(path string) error
|
||||||
ShowInFolder(path string, isDir bool) error
|
ShowInFolder(path string, isDir bool) error
|
||||||
|
|
@ -1266,6 +1281,44 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
|
||||||
return ""
|
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.
|
// ListVaultFiles lists a vault-relative directory for a plugin with files.read.
|
||||||
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
|
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
|
||||||
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,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/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||||
|
|
@ -27,6 +28,26 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"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 {
|
func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server {
|
||||||
t.Helper()
|
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) {
|
func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
v := vault.NewVault(nil)
|
v := vault.NewVault(nil)
|
||||||
|
|
@ -134,6 +167,44 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s
|
||||||
return app, root
|
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
|
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func (r *Registry) registerDefaults() {
|
||||||
{Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true},
|
{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.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
|
||||||
{Name: "storage.migrations", Description: "Run database migrations in plugin 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.publish", Description: "Publish events to the event bus", Dangerous: false},
|
||||||
{Name: "events.subscribe", Description: "Subscribe to events on 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},
|
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue