feat: expose scheduled notifications to plugins
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user