feat: expose scheduled notifications to plugins

This commit is contained in:
2026-07-14 02:37:28 +08:00
parent 67753927cb
commit 96dd499e0b
5 changed files with 174 additions and 0 deletions
+53
View File
@@ -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 {
+71
View File
@@ -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) {
+1
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: "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},