feat: expose scheduled notifications to plugins

This commit is contained in:
mirivlad 2026-07-14 02:37:28 +08:00
parent 67753927cb
commit 96dd499e0b
5 changed files with 174 additions and 0 deletions

View File

@ -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');

View File

@ -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');

View File

@ -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 {

View File

@ -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) {

View File

@ -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},