feat: host activity providers

This commit is contained in:
mirivlad 2026-06-28 16:14:59 +08:00
parent 93597a2c45
commit 9729b432d6
3 changed files with 307 additions and 1 deletions

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -34,6 +35,9 @@ var newSyncClient = syncsvc.NewClient
var emitFrontendEvent = runtime.EventsEmit var emitFrontendEvent = runtime.EventsEmit
const pluginEventRuntimeName = "verstak:plugin-event" const pluginEventRuntimeName = "verstak:plugin-event"
const activityGlobalKey = "events:global"
const activityWorkspacePrefix = "events:workspace:"
const maxActivityEvents = 250
// App is the main application struct exposed to the Wails frontend. // App is the main application struct exposed to the Wails frontend.
type App struct { type App struct {
@ -53,6 +57,7 @@ type App struct {
workspace *workspace.Manager workspace *workspace.Manager
syncSvc *syncsvc.Service syncSvc *syncsvc.Service
debug bool debug bool
activityEvents map[string]bool
} }
type externalOpenService interface { type externalOpenService interface {
@ -76,7 +81,7 @@ func NewApp(
syncService *syncsvc.Service, syncService *syncsvc.Service,
debugEnabled bool, debugEnabled bool,
) *App { ) *App {
return &App{ app := &App{
capRegistry: capReg, capRegistry: capReg,
contribRegistry: contribReg, contribRegistry: contribReg,
permRegistry: permReg, permRegistry: permReg,
@ -92,7 +97,10 @@ func NewApp(
workspace: workspaceMgr, workspace: workspaceMgr,
syncSvc: syncService, syncSvc: syncService,
debug: debugEnabled, debug: debugEnabled,
activityEvents: make(map[string]bool),
} }
app.ensureActivityProviderSubscriptions()
return app
} }
func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences { func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences {
@ -125,6 +133,7 @@ func (a *App) ensureWorkbench() *coreworkbench.Router {
// Startup is called when the app starts. Sets the Wails context for dialogs. // Startup is called when the app starts. Sets the Wails context for dialogs.
func (a *App) Startup(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.ensureActivityProviderSubscriptions()
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
} }
@ -171,6 +180,135 @@ func hasString(items []string, want string) bool {
return false return false
} }
func (a *App) ensureActivityProviderSubscriptions() {
if a.eventBus == nil || a.contribRegistry == nil {
return
}
if a.activityEvents == nil {
a.activityEvents = make(map[string]bool)
}
for _, provider := range a.contribRegistry.ActivityProviders() {
for _, eventName := range provider.Item.Events {
eventName = strings.TrimSpace(eventName)
if eventName == "" || a.activityEvents[eventName] {
continue
}
a.activityEvents[eventName] = true
a.eventBus.Subscribe(eventName, func(event events.Event) {
a.recordActivityProviderEvent(event)
})
}
}
}
func (a *App) recordActivityProviderEvent(event events.Event) {
if a.storage == nil || a.contribRegistry == nil {
return
}
for _, provider := range a.contribRegistry.ActivityProviders() {
if !hasString(provider.Item.Events, event.Name) {
continue
}
if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil {
continue
}
if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil {
log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err)
}
}
}
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
settings, err := a.storage.ReadPluginSettings(pluginID)
if err != nil {
return err
}
key := activityGlobalKey
if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" {
key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace))
key = strings.ReplaceAll(key, "+", "%20")
}
eventsList := []interface{}{activity}
if existing, ok := settings[key].([]interface{}); ok {
eventsList = append(eventsList, existing...)
} else if existingMaps, ok := settings[key].([]map[string]interface{}); ok {
for _, item := range existingMaps {
eventsList = append(eventsList, item)
}
}
if len(eventsList) > maxActivityEvents {
eventsList = eventsList[:maxActivityEvents]
}
settings[key] = eventsList
return a.storage.WritePluginSettings(pluginID, settings)
}
func activityFromEvent(event events.Event) map[string]interface{} {
payload := eventPayloadMap(event.Payload)
now := time.Now().UTC().Format(time.RFC3339Nano)
occurredAt := firstPayloadText(payload, "occurredAt", "capturedAt")
if occurredAt == "" {
occurredAt = event.Timestamp
}
if occurredAt == "" {
occurredAt = now
}
workspaceRoot := firstPayloadText(payload, "workspaceRootPath", "workspaceName", "workspaceNodeId")
if workspaceRoot == "" {
workspaceRoot = workspaceRootFromRelativePath(firstPayloadText(payload, "path"))
}
return map[string]interface{}{
"activityId": fmt.Sprintf("activity-%d", time.Now().UnixNano()),
"type": event.Name,
"title": activityTitle(event.Name, payload),
"summary": activitySummary(event.Name, payload),
"occurredAt": occurredAt,
"receivedAt": now,
"sourcePluginId": firstPayloadText(payload, "pluginId", "sourcePluginId"),
"workspaceRootPath": workspaceRoot,
"payload": payload,
}
}
func eventPayloadMap(payload interface{}) map[string]interface{} {
switch value := payload.(type) {
case map[string]interface{}:
return value
case map[string]string:
result := make(map[string]interface{}, len(value))
for key, item := range value {
result[key] = item
}
return result
default:
return map[string]interface{}{}
}
}
func firstPayloadText(payload map[string]interface{}, keys ...string) string {
for _, key := range keys {
value := strings.TrimSpace(fmt.Sprint(payload[key]))
if value != "" && value != "<nil>" {
return value
}
}
return ""
}
func activityTitle(eventName string, payload map[string]interface{}) string {
if title := firstPayloadText(payload, "title", "name", "path", "url", "captureId"); title != "" {
return title
}
return eventName
}
func activitySummary(eventName string, payload map[string]interface{}) string {
if summary := firstPayloadText(payload, "text", "summary", "description", "path", "url", "domain"); summary != "" {
return summary
}
return eventName
}
// ─── Plugin Manager API ───────────────────────────────────── // ─── Plugin Manager API ─────────────────────────────────────
// GetPlugins returns all discovered plugins. // GetPlugins returns all discovered plugins.
@ -461,6 +599,7 @@ func (a *App) ReloadPlugins() (int, string) {
} }
a.plugins = plugins a.plugins = plugins
a.ensureActivityProviderSubscriptions()
var buf strings.Builder var buf strings.Builder
buf.WriteString("discovery complete") buf.WriteString("discovery complete")
@ -706,6 +845,9 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string,
}); err != nil { }); err != nil {
return err.Error() return err.Error()
} }
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
"operation": opType,
})
return "" return ""
} }
@ -725,6 +867,10 @@ func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
}); err != nil { }); err != nil {
return err.Error() return err.Error()
} }
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
"operation": syncsvc.OpCreate,
"type": string(corefiles.FileTypeFolder),
})
return "" return ""
} }
@ -749,6 +895,11 @@ func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath st
}); err != nil { }); err != nil {
return err.Error() return err.Error()
} }
a.publishFileActivity("file.changed", pluginID, toRelativePath, map[string]interface{}{
"operation": syncsvc.OpMove,
"fromPath": fromRelativePath,
"type": string(meta.Type),
})
return "" return ""
} }
@ -773,6 +924,10 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu
}); err != nil { }); err != nil {
return corefiles.TrashResult{}, err.Error() return corefiles.TrashResult{}, err.Error()
} }
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
"operation": syncsvc.OpDelete,
"type": string(meta.Type),
})
return result, "" return result, ""
} }
@ -827,6 +982,38 @@ func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload inte
return a.syncSvc.RecordOp(entityType, entityID, opType, payload) return a.syncSvc.RecordOp(entityType, entityID, opType, payload)
} }
func (a *App) publishFileActivity(eventName, pluginID, relativePath string, extra map[string]interface{}) {
if a.eventBus == nil {
return
}
path := strings.TrimSpace(filepath.ToSlash(relativePath))
payload := map[string]interface{}{
"path": path,
"title": path,
"workspaceRootPath": workspaceRootFromRelativePath(path),
"pluginId": pluginID,
}
for key, value := range extra {
payload[key] = value
}
a.eventBus.Publish(events.Event{
Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: payload,
})
}
func workspaceRootFromRelativePath(relativePath string) string {
path := strings.Trim(strings.TrimSpace(filepath.ToSlash(relativePath)), "/")
if path == "" {
return ""
}
if idx := strings.Index(path, "/"); idx >= 0 {
return path[:idx]
}
return path
}
func syncEntityTypeForFileType(fileType corefiles.FileType) string { func syncEntityTypeForFileType(fileType corefiles.FileType) string {
if fileType == corefiles.FileTypeFolder { if fileType == corefiles.FileTypeFolder {
return syncsvc.EntityFolder return syncsvc.EntityFolder

View File

@ -390,6 +390,111 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
} }
} }
func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.write"})
bus := events.NewBus()
app.eventBus = bus
if errStr := app.CreateVaultFolder("files.plugin", "Project"); errStr != "" {
t.Fatalf("CreateVaultFolder Project: %s", errStr)
}
if errStr := app.CreateVaultFolder("files.plugin", "Project/Notes"); errStr != "" {
t.Fatalf("CreateVaultFolder Project/Notes: %s", errStr)
}
var received []events.Event
bus.Subscribe("file.changed", func(event events.Event) {
received = append(received, event)
})
if errStr := app.WriteVaultTextFile("files.plugin", "Project/Notes/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
if len(received) != 1 {
t.Fatalf("received %d file.changed events, want 1", len(received))
}
payload, ok := received[0].Payload.(map[string]interface{})
if !ok {
t.Fatalf("payload = %#v, want map[string]interface{}", received[0].Payload)
}
if payload["path"] != "Project/Notes/one.txt" {
t.Fatalf("payload path = %#v", payload["path"])
}
if payload["workspaceRootPath"] != "Project" {
t.Fatalf("payload workspaceRootPath = %#v, want Project", payload["workspaceRootPath"])
}
if payload["pluginId"] != "files.plugin" {
t.Fatalf("payload pluginId = %#v, want files.plugin", payload["pluginId"])
}
if received[0].Timestamp == "" {
t.Fatal("event timestamp is empty")
}
}
func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.write"})
app.eventBus = events.NewBus()
app.storage = storage.New(app.vault)
app.contribRegistry = contribution.NewRegistry()
app.plugins = append(app.plugins, plugin.Plugin{
Manifest: plugin.Manifest{
ID: "verstak.activity",
Name: "Activity",
Version: "1.0.0",
Provides: []string{"activity.log"},
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
})
if errStr := app.CreateVaultFolder("files.plugin", "Project"); errStr != "" {
t.Fatalf("CreateVaultFolder Project: %s", errStr)
}
if errStr := app.CreateVaultFolder("files.plugin", "Project/Notes"); errStr != "" {
t.Fatalf("CreateVaultFolder Project/Notes: %s", errStr)
}
app.contribRegistry.Register("verstak.activity", &plugin.Contributions{
ActivityProviders: []plugin.ContributionActivityProvider{{
ID: "verstak.activity.log",
Events: []string{"file.changed"},
Handler: "recordActivityEvent",
}},
})
app.ensureActivityProviderSubscriptions()
if errStr := app.WriteVaultTextFile("files.plugin", "Project/Notes/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" {
t.Fatalf("WriteVaultTextFile: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings("verstak.activity")
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["events:workspace:Project"].([]interface{})
if !ok {
t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"])
}
if len(stored) != 1 {
t.Fatalf("stored %d activity events, want 1", len(stored))
}
activity, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("activity = %#v, want map[string]interface{}", stored[0])
}
if activity["type"] != "file.changed" {
t.Fatalf("activity type = %#v, want file.changed", activity["type"])
}
if activity["workspaceRootPath"] != "Project" {
t.Fatalf("activity workspaceRootPath = %#v, want Project", activity["workspaceRootPath"])
}
if activity["sourcePluginId"] != "files.plugin" {
t.Fatalf("activity sourcePluginId = %#v, want files.plugin", activity["sourcePluginId"])
}
}
func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) { func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.openExternal"}) app, root := newFilesTestApp(t, []string{"files.openExternal"})
filePath := filepath.Join(root, "Docs", "one.txt") filePath := filepath.Join(root, "Docs", "one.txt")

View File

@ -316,6 +316,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
return result return result
} }
func (r *Registry) ActivityProviders() []ContributionActivityProvider {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ContributionActivityProvider, len(r.activityProviders))
copy(result, r.activityProviders)
sort.Slice(result, func(i, j int) bool {
if result[i].PluginID != result[j].PluginID {
return result[i].PluginID < result[j].PluginID
}
return result[i].Item.ID < result[j].Item.ID
})
return result
}
func (r *Registry) OpenProviders() []ContributionOpenProvider { func (r *Registry) OpenProviders() []ContributionOpenProvider {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()