Files
wanderer/db/pluginsystem/manager.go
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

420 lines
12 KiB
Go

package pluginsystem
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// Manager coordinates local plugin discovery with the installed_plugins cache.
// It is intentionally small: request hot paths should read cached manifests,
// while list/cron entrypoints refresh the cache from data/plugins first.
type Manager struct {
App core.App
Dir string
}
// PluginInfo is the UI-facing view of an installed plugin. It combines the
// static manifest with runtime availability and embedded icon data.
type PluginInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
IconDark string `json:"iconDark,omitempty"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Path string `json:"path"`
Capabilities []string `json:"capabilities"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Manifest Manifest `json:"manifest"`
}
// NewManager creates a manager for the configured plugin directory. Tests can
// pass a custom dir; production callers use the resolved runtime plugin
// directory.
func NewManager(app core.App, dir string) *Manager {
if dir == "" {
dir = PluginDir()
}
return &Manager{App: app, Dir: dir}
}
// ListLocalPlugins returns installed plugins in the shape consumed by the
// settings UI. It reads from installed_plugins first so listing does not need to
// parse every manifest from disk after the cache has been refreshed.
func (m *Manager) ListLocalPlugins(context.Context) ([]PluginInfo, error) {
plugins, err := LoadInstalledPlugins(m.App, m.Dir)
if err != nil {
return nil, err
}
infos := make([]PluginInfo, 0, len(plugins))
infoByPath := map[string]int{}
for _, plugin := range plugins {
status := "available"
record, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": plugin.Manifest.ID},
)
if record != nil && record.GetString("status") != "" {
status = record.GetString("status")
}
errorMessage := ""
if record != nil {
errorMessage = record.GetString("error")
}
icon, iconDark := pluginIcons(plugin)
infos = append(infos, PluginInfo{
ID: plugin.Manifest.ID,
Type: plugin.Manifest.Type,
Name: plugin.Manifest.Name,
DisplayName: stringMetadata(plugin.Manifest.Metadata, "displayName"),
Description: plugin.Manifest.Description,
Icon: icon,
IconDark: iconDark,
Version: plugin.Manifest.Version,
Runtime: plugin.Manifest.Runtime.Type,
Path: plugin.Dir,
Capabilities: capabilityNames(plugin.Manifest.Capabilities),
Status: status,
Error: errorMessage,
Manifest: plugin.Manifest,
})
infoByPath[filepath.Clean(plugin.Dir)] = len(infos) - 1
}
_, issues, err := DiscoverLocalPlugins(m.Dir)
if err != nil {
return nil, err
}
for _, issue := range issues {
if index, ok := infoByPath[filepath.Clean(issue.Dir)]; ok {
infos[index].Status = "error"
infos[index].Error = issue.Error
continue
}
infos = append(infos, PluginInfo{
ID: issue.ID,
Type: PluginTypeTrails,
Name: issue.Name,
Path: issue.Dir,
Status: "error",
Error: issue.Error,
Runtime: RuntimeWASM,
Manifest: Manifest{
ID: issue.ID,
Type: PluginTypeTrails,
Name: issue.Name,
Runtime: RuntimeManifest{
Type: RuntimeWASM,
},
},
})
}
return infos, nil
}
// pluginIcons embeds optional light/dark icon files from the plugin bundle as
// data URLs so the frontend does not need direct filesystem access.
func pluginIcons(plugin LocalPlugin) (string, string) {
icons, _ := plugin.Manifest.Metadata["icons"].(map[string]any)
return pluginIcon(plugin.Dir, stringMetadata(icons, "light")), pluginIcon(plugin.Dir, stringMetadata(icons, "dark"))
}
func stringMetadata(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func pluginIcon(pluginDir string, iconPath string) string {
iconPath = strings.TrimSpace(iconPath)
if iconPath == "" {
return ""
}
cleanPath := filepath.Clean(iconPath)
if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
return ""
}
fullPath := filepath.Join(pluginDir, cleanPath)
data, err := os.ReadFile(fullPath)
if err != nil {
return ""
}
contentType := "image/svg+xml"
switch strings.ToLower(filepath.Ext(fullPath)) {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".webp":
contentType = "image/webp"
}
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// SyncInstalledPlugins scans the runtime plugin directory and upserts
// installed_plugins records.
// This keeps the manifest snapshot available even when later code paths should
// avoid repeated disk IO.
func (m *Manager) SyncInstalledPlugins(ctx context.Context) error {
plugins, issues, err := DiscoverLocalPlugins(m.Dir)
if err != nil {
return err
}
collection, err := m.App.FindCollectionByNameOrId("installed_plugins")
if err != nil {
return err
}
activePaths := activePluginPaths(plugins, issues)
if err := m.deleteStaleInstalledPlugins(ctx, activePaths); err != nil {
return err
}
for _, issue := range issues {
if err := ctx.Err(); err != nil {
return err
}
m.App.Logger().Warn("plugin setup error", "plugin", issue.ID, "path", issue.Dir, "error", issue.Error)
if err := m.savePluginIssue(collection, issue); err != nil {
return err
}
}
for _, plugin := range plugins {
if err := ctx.Err(); err != nil {
return err
}
record, err := m.findPluginRecord(collection, plugin)
if err != nil {
return err
}
record.Set("plugin_id", plugin.Manifest.ID)
record.Set("name", plugin.Manifest.Name)
record.Set("type", plugin.Manifest.Type)
record.Set("version", plugin.Manifest.Version)
record.Set("runtime", plugin.Manifest.Runtime.Type)
record.Set("path", plugin.Dir)
record.Set("status", "available")
record.Set("error", "")
manifestJSON, err := marshalManifest(plugin.Manifest)
if err != nil {
return fmt.Errorf("encode installed plugin %s manifest: %w", plugin.Manifest.ID, err)
}
record.Set("manifest", manifestJSON)
record.Set("config", mergeDefaultConfig(defaultConfig(plugin.Manifest), JSONMapFromRecord(record, "config")))
if err := m.App.Save(record); err != nil {
return fmt.Errorf("save installed plugin %s: %w", plugin.Manifest.ID, err)
}
}
return nil
}
func activePluginPaths(plugins []LocalPlugin, issues []LocalPluginIssue) map[string]bool {
paths := make(map[string]bool, len(plugins)+len(issues))
for _, plugin := range plugins {
if plugin.Dir != "" {
paths[filepath.Clean(plugin.Dir)] = true
}
}
for _, issue := range issues {
if issue.Dir != "" {
paths[filepath.Clean(issue.Dir)] = true
}
}
return paths
}
func (m *Manager) deleteStaleInstalledPlugins(ctx context.Context, activePaths map[string]bool) error {
records, err := m.App.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
if err != nil {
return err
}
for _, record := range records {
if err := ctx.Err(); err != nil {
return err
}
path := strings.TrimSpace(record.GetString("path"))
if path != "" && activePaths[filepath.Clean(path)] {
continue
}
if err := m.App.Delete(record); err != nil {
return fmt.Errorf("delete stale installed plugin %s: %w", record.GetString("plugin_id"), err)
}
}
return nil
}
func (m *Manager) findPluginRecord(collection *core.Collection, plugin LocalPlugin) (*core.Record, error) {
recordByID, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": plugin.Manifest.ID},
)
var recordByPath *core.Record
if plugin.Dir != "" {
recordByPath, _ = m.App.FindFirstRecordByFilter(
"installed_plugins",
"path={:path}",
dbx.Params{"path": plugin.Dir},
)
}
if recordByID != nil && recordByPath != nil && recordByID.Id != recordByPath.Id {
if err := m.App.Delete(recordByPath); err != nil {
return nil, fmt.Errorf("delete superseded installed plugin %s: %w", recordByPath.GetString("plugin_id"), err)
}
}
if recordByID != nil {
return recordByID, nil
}
if recordByPath != nil {
return recordByPath, nil
}
return core.NewRecord(collection), nil
}
func (m *Manager) savePluginIssue(collection *core.Collection, issue LocalPluginIssue) error {
recordID := pluginIssueRecordID(issue)
record, _ := m.App.FindFirstRecordByFilter(
"installed_plugins",
"plugin_id={:plugin_id}",
dbx.Params{"plugin_id": recordID},
)
if record == nil && issue.Dir != "" {
record, _ = m.App.FindFirstRecordByFilter(
"installed_plugins",
"path={:path}",
dbx.Params{"path": issue.Dir},
)
}
if record == nil {
record = core.NewRecord(collection)
record.Set("plugin_id", recordID)
}
record.Set("name", issue.Name)
record.Set("type", PluginTypeTrails)
record.Set("version", "unknown")
record.Set("runtime", RuntimeWASM)
record.Set("path", issue.Dir)
record.Set("manifest", map[string]any{
"id": record.GetString("plugin_id"),
"type": PluginTypeTrails,
"name": issue.Name,
})
record.Set("status", "error")
record.Set("error", issue.Error)
if err := m.App.Save(record); err != nil {
return fmt.Errorf("save plugin setup error %s: %w", issue.ID, err)
}
return nil
}
func pluginIssueRecordID(issue LocalPluginIssue) string {
originalID := strings.TrimSpace(issue.ID)
id := strings.ToLower(originalID)
var builder strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-':
builder.WriteRune(r)
default:
builder.WriteRune('-')
}
}
result := strings.Trim(builder.String(), "-_")
if result == "" {
result = "plugin-setup-error"
}
if originalID != result || !pluginIDPattern.MatchString(result) {
result = strings.Trim(result, "-_")
if result == "" {
result = "plugin-setup-error"
}
result = result + "-" + pluginIssueHash(issue)
}
if len(result) > 128 {
hash := pluginIssueHash(issue)
prefixLength := 128 - len(hash) - 1
result = strings.Trim(result[:prefixLength], "-_") + "-" + hash
}
if pluginIDPattern.MatchString(result) {
return result
}
return "plugin-setup-error-" + pluginIssueHash(issue)
}
func pluginIssueHash(issue LocalPluginIssue) string {
hash := fnv.New32a()
_, _ = hash.Write([]byte(issue.Dir))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(issue.ID))
return fmt.Sprintf("%08x", hash.Sum32())
}
func marshalManifest(manifest Manifest) (map[string]any, error) {
data, err := json.Marshal(manifest)
if err != nil {
return nil, err
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
func defaultConfig(manifest Manifest) map[string]any {
hostConfig, _ := CloneJSONValue(manifest.HostConfig).(map[string]any)
if hostConfig == nil {
hostConfig = map[string]any{}
}
config := map[string]any{
"host": hostConfig,
}
pluginConfig := map[string]any{}
for _, field := range manifest.ConfigSchema {
if field.Key == "" || field.Default == nil {
continue
}
pluginConfig[field.Key] = CloneJSONValue(field.Default)
}
config["plugin"] = pluginConfig
return config
}
func mergeDefaultConfig(defaults map[string]any, current map[string]any) map[string]any {
if len(defaults) == 0 {
return current
}
merged := CloneJSONMap(defaults)
MergePluginConfig(merged, current)
return merged
}
func MergePluginConfig(dst map[string]any, src map[string]any) {
DeepMergeConfigWithReplaceKeys(dst, src, map[string]bool{
"categoryMapping": true,
})
}
func capabilityNames(capabilities []CapabilityManifest) []string {
names := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
names = append(names, capability.Name+"."+capability.Version)
}
return names
}