546 lines
18 KiB
Go
546 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/protocol"
|
|
)
|
|
|
|
const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
|
|
|
|
// PluginView is the backend representation consumed by the single frontend
|
|
// plugin center. Protocol-specific runtime data is nested under Protocol so
|
|
// the public plugin contract can grow without adding another center API.
|
|
type PluginView struct {
|
|
Manifest PluginManifestView `json:"manifest"`
|
|
Source string `json:"source"`
|
|
FileName string `json:"fileName"`
|
|
Package string `json:"package"`
|
|
SHA256 string `json:"sha256"`
|
|
InstalledAt time.Time `json:"installedAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
Management PluginManagementView `json:"management"`
|
|
}
|
|
|
|
type PluginManifestView struct {
|
|
APIVersion string `json:"apiVersion"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Entry string `json:"entry,omitempty"`
|
|
Surfaces []string `json:"surfaces,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Documentation string `json:"documentation,omitempty"`
|
|
Author string `json:"author,omitempty"`
|
|
Permissions []string `json:"permissions"`
|
|
Trusted bool `json:"trusted"`
|
|
Runtime protocol.ManifestRuntime `json:"runtime,omitempty"`
|
|
Configuration protocol.ManifestConfiguration `json:"configuration,omitempty"`
|
|
Contributes protocol.ManifestContributions `json:"contributes"`
|
|
}
|
|
|
|
type pluginRecord struct {
|
|
Raw []byte
|
|
Metadata protocol.Metadata
|
|
Source string
|
|
FileName string
|
|
PackagePath string
|
|
PackageSHA256 string
|
|
SHA256 string
|
|
InstalledAt time.Time
|
|
UpdatedAt time.Time
|
|
Status string
|
|
Error string
|
|
}
|
|
|
|
type pluginRuntime struct {
|
|
mu sync.RWMutex
|
|
mutationMu sync.Mutex
|
|
registryPath string
|
|
packageDir string
|
|
plugins map[string]pluginRecord
|
|
registry *protocol.Registry
|
|
}
|
|
|
|
type pluginRegistryRecord struct {
|
|
ID string `json:"id"`
|
|
Raw json.RawMessage `json:"manifest"`
|
|
Source string `json:"source"`
|
|
FileName string `json:"fileName,omitempty"`
|
|
PackagePath string `json:"packagePath,omitempty"`
|
|
PackageSHA256 string `json:"packageSha256,omitempty"`
|
|
InstalledAt time.Time `json:"installedAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
|
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create plugin registry directory: %w", err)
|
|
}
|
|
packageDir := filepath.Join(dataDir, "plugin-packages")
|
|
if err := os.MkdirAll(packageDir, 0o700); err != nil {
|
|
return nil, fmt.Errorf("create plugin package directory: %w", err)
|
|
}
|
|
center := &pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json"), packageDir: packageDir, plugins: make(map[string]pluginRecord)}
|
|
if err := center.bootstrapBundledPlugins(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := center.reload(); err != nil {
|
|
return nil, err
|
|
}
|
|
return center, nil
|
|
}
|
|
|
|
func (c *pluginRuntime) bootstrapBundledPlugins() error {
|
|
stored, err := c.readRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byID := make(map[string]pluginRegistryRecord, len(stored))
|
|
for _, record := range stored {
|
|
byID[record.ID] = record
|
|
}
|
|
items := protocol.Builtins().List("", "", true)
|
|
bundledIDs := make(map[string]struct{}, len(items)+2)
|
|
for _, metadata := range items {
|
|
bundledIDs[metadata.ID] = struct{}{}
|
|
protocol.AttachDocumentation(&metadata)
|
|
metadata.Installable = true
|
|
manifest, declarative := protocol.BundledManifest(metadata.ID)
|
|
existing := []byte(nil)
|
|
if record, ok := byID[metadata.ID]; ok {
|
|
existing = record.Raw
|
|
var installed protocol.Manifest
|
|
if err := json.Unmarshal(existing, &installed); err != nil {
|
|
return fmt.Errorf("decode bundled plugin %s: %w", metadata.ID, err)
|
|
}
|
|
expectedBackend := "host:" + metadata.ID
|
|
if declarative {
|
|
expectedBackend = "declarative"
|
|
}
|
|
if installed.Runtime.Backend != expectedBackend {
|
|
return fmt.Errorf("protocol id %q is reserved by a bundled plugin", metadata.ID)
|
|
}
|
|
if metadata.Enabled {
|
|
metadata.Enabled = installed.Metadata.Enabled
|
|
}
|
|
}
|
|
if declarative {
|
|
manifest.Metadata.Enabled = metadata.Enabled
|
|
manifest.Metadata.Installable = true
|
|
} else {
|
|
metadata.Execution = "host:" + metadata.ID
|
|
manifest = protocol.Manifest{
|
|
APIVersion: "yingce.plugin/v1",
|
|
Metadata: metadata,
|
|
Runtime: protocol.ManifestRuntime{Backend: "host:" + metadata.ID},
|
|
Permissions: []string{"generation.run"},
|
|
Contributes: protocol.ManifestContributions{Providers: []protocol.ManifestProvider{{
|
|
ID: metadata.ID, Label: metadata.Name, Capabilities: metadata.Categories, Scopes: metadata.Scopes,
|
|
Parameters: metadata.Parameters, RequiresPublicMediaURLs: metadata.RequiresPublicMediaURLs,
|
|
Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, Response: protocol.ManifestResponse{},
|
|
}}},
|
|
}
|
|
}
|
|
data, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("encode bundled protocol %s: %w", metadata.ID, err)
|
|
}
|
|
record := byID[metadata.ID]
|
|
if !bytes.Equal(record.Raw, data) {
|
|
now := time.Now().UTC()
|
|
if record.InstalledAt.IsZero() {
|
|
record.InstalledAt = now
|
|
}
|
|
record.UpdatedAt = now
|
|
}
|
|
record.ID, record.Raw, record.Source, record.PackagePath = metadata.ID, data, "bundled", ""
|
|
byID[metadata.ID] = record
|
|
}
|
|
for _, workflow := range bundledWorkflowPluginManifests() {
|
|
bundledIDs[workflow.Metadata.ID] = struct{}{}
|
|
data, err := json.Marshal(workflow)
|
|
if err != nil {
|
|
return fmt.Errorf("encode bundled workflow plugin %s: %w", workflow.Metadata.ID, err)
|
|
}
|
|
record := byID[workflow.Metadata.ID]
|
|
if len(record.Raw) > 0 {
|
|
var installed protocol.Manifest
|
|
if err := json.Unmarshal(record.Raw, &installed); err != nil {
|
|
return fmt.Errorf("decode bundled workflow plugin %s: %w", workflow.Metadata.ID, err)
|
|
}
|
|
workflow.Metadata.Enabled = installed.Metadata.Enabled
|
|
data, err = json.Marshal(workflow)
|
|
if err != nil {
|
|
return fmt.Errorf("encode bundled workflow plugin %s: %w", workflow.Metadata.ID, err)
|
|
}
|
|
}
|
|
if !bytes.Equal(record.Raw, data) {
|
|
now := time.Now().UTC()
|
|
if record.InstalledAt.IsZero() {
|
|
record.InstalledAt = now
|
|
}
|
|
record.UpdatedAt = now
|
|
}
|
|
record.ID, record.Raw, record.Source, record.PackagePath = workflow.Metadata.ID, data, "bundled", ""
|
|
byID[workflow.Metadata.ID] = record
|
|
}
|
|
result := make([]pluginRegistryRecord, 0, len(byID))
|
|
for _, record := range byID {
|
|
if record.Source == "bundled" {
|
|
if _, exists := bundledIDs[record.ID]; !exists {
|
|
// Bundled records are derived from the current host registry. Dropping
|
|
// an obsolete record prevents a removed built-in protocol from surviving
|
|
// as a second, stale provider after a host upgrade.
|
|
continue
|
|
}
|
|
}
|
|
result = append(result, record)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
|
return c.writeRegistry(result)
|
|
}
|
|
|
|
func (c *pluginRuntime) reload() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
stored, err := c.readRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plugins := make(map[string]pluginRecord)
|
|
for _, storedRecord := range stored {
|
|
data := storedRecord.Raw
|
|
if len(data) > protocolPluginMaxBytes {
|
|
return fmt.Errorf("plugin %s exceeds %d bytes", storedRecord.ID, protocolPluginMaxBytes)
|
|
}
|
|
var manifest protocol.Manifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return fmt.Errorf("decode plugin %s: %w", storedRecord.ID, err)
|
|
}
|
|
metadata := manifest.Metadata
|
|
if strings.TrimSpace(metadata.ID) == "" {
|
|
return fmt.Errorf("plugin %s has no metadata id", storedRecord.ID)
|
|
}
|
|
if _, exists := plugins[metadata.ID]; exists {
|
|
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
|
|
}
|
|
packageSHA256 := storedRecord.PackageSHA256
|
|
if packageSHA256 == "" {
|
|
packageSHA256 = pluginHash(data)
|
|
}
|
|
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"}
|
|
}
|
|
registry, err := protocol.NewRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for id, record := range plugins {
|
|
adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, func(execution string) (protocol.Adapter, bool) {
|
|
return protocol.Builtins().Resolve(execution)
|
|
})
|
|
if loadErr != nil {
|
|
record.Metadata.Enabled = false
|
|
record.Metadata.UnavailableReason = loadErr.Error()
|
|
record.Error = loadErr.Error()
|
|
_ = registry.Register(protocol.UnavailableAdapter{Info: record.Metadata})
|
|
plugins[id] = record
|
|
continue
|
|
}
|
|
if !record.Metadata.Enabled {
|
|
record.Status = "disabled"
|
|
for _, adapter := range adapters {
|
|
info := adapter.Metadata()
|
|
info.Enabled = false
|
|
_ = registry.Register(protocol.UnavailableAdapter{Info: info})
|
|
}
|
|
plugins[id] = record
|
|
continue
|
|
}
|
|
registrationFailed := false
|
|
for _, adapter := range adapters {
|
|
if err := registry.Register(adapter); err != nil {
|
|
record.Error = err.Error()
|
|
registrationFailed = true
|
|
}
|
|
}
|
|
if registrationFailed {
|
|
plugins[id] = record
|
|
continue
|
|
}
|
|
record.Status = "enabled"
|
|
plugins[id] = record
|
|
}
|
|
c.plugins = plugins
|
|
c.registry = registry
|
|
return nil
|
|
}
|
|
|
|
func (c *pluginRuntime) list() []PluginView {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
items := make([]PluginView, 0, len(c.plugins))
|
|
for _, item := range c.plugins {
|
|
items = append(items, PluginView{Manifest: pluginManifestView(item.Raw, item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, Package: protocol.PluginPackageFormat, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error})
|
|
}
|
|
sort.Slice(items, func(i, j int) bool { return items[i].Manifest.ID < items[j].Manifest.ID })
|
|
return items
|
|
}
|
|
|
|
func (c *pluginRuntime) registrySnapshot() *protocol.Registry {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.registry
|
|
}
|
|
|
|
func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) {
|
|
c.mutationMu.Lock()
|
|
defer c.mutationMu.Unlock()
|
|
if len(data) == 0 || len(data) > protocol.PluginPackageMaxBytes {
|
|
return PluginView{}, fmt.Errorf("plugin package must be between 1 and %d bytes", protocol.PluginPackageMaxBytes)
|
|
}
|
|
pkg, err := protocol.ParsePluginPackage(data)
|
|
if err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
manifest := pkg.Manifest
|
|
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
|
|
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
|
|
}
|
|
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
c.mu.RLock()
|
|
existing, exists := c.plugins[manifest.Metadata.ID]
|
|
c.mu.RUnlock()
|
|
if exists && existing.Source == "bundled" {
|
|
return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
|
|
}
|
|
manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled
|
|
manifestData, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
hash := pluginHash(data)
|
|
packageName := filepath.Base(strings.TrimSpace(fileName))
|
|
if packageName == "." || packageName == "" || packageName == string(filepath.Separator) {
|
|
packageName = manifest.Metadata.ID + ".yingce-plugin"
|
|
}
|
|
packagePath := filepath.Join(c.packageDir, hash+".yingce-plugin")
|
|
if err := writePluginFile(packagePath, data); err != nil {
|
|
return PluginView{}, fmt.Errorf("保存插件包失败:%w", err)
|
|
}
|
|
stored, err := c.readRegistry()
|
|
if err != nil {
|
|
_ = os.Remove(packagePath)
|
|
return PluginView{}, err
|
|
}
|
|
previousStored := append([]pluginRegistryRecord(nil), stored...)
|
|
now := time.Now().UTC()
|
|
newRecord := pluginRegistryRecord{ID: manifest.Metadata.ID, Raw: manifestData, Source: "uploaded", FileName: packageName, PackagePath: filepath.Base(packagePath), PackageSHA256: hash, InstalledAt: now, UpdatedAt: now}
|
|
if exists {
|
|
newRecord.InstalledAt = existing.InstalledAt
|
|
for index := range stored {
|
|
if stored[index].ID == manifest.Metadata.ID {
|
|
stored[index] = newRecord
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
stored = append(stored, newRecord)
|
|
}
|
|
if err := c.writeRegistry(stored); err != nil {
|
|
_ = os.Remove(packagePath)
|
|
return PluginView{}, fmt.Errorf("保存插件失败:%w", err)
|
|
}
|
|
if err := c.reload(); err != nil {
|
|
_ = c.writeRegistry(previousStored)
|
|
_ = c.reload()
|
|
_ = os.Remove(packagePath)
|
|
return PluginView{}, err
|
|
}
|
|
if exists && existing.PackagePath != "" && existing.PackagePath != filepath.Base(packagePath) {
|
|
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(existing.PackagePath)))
|
|
}
|
|
for _, item := range c.list() {
|
|
if item.Manifest.ID == manifest.Metadata.ID {
|
|
return item, nil
|
|
}
|
|
}
|
|
return PluginView{}, errors.New("插件保存后未加载")
|
|
}
|
|
|
|
func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) {
|
|
c.mutationMu.Lock()
|
|
defer c.mutationMu.Unlock()
|
|
c.mu.RLock()
|
|
record, ok := c.plugins[strings.TrimSpace(id)]
|
|
c.mu.RUnlock()
|
|
if !ok {
|
|
return PluginView{}, fmt.Errorf("插件 %q 不存在", id)
|
|
}
|
|
var manifest protocol.Manifest
|
|
if err := json.Unmarshal(record.Raw, &manifest); err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
manifest.Metadata.Enabled = enabled
|
|
data, err := json.Marshal(manifest)
|
|
if err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
stored, err := c.readRegistry()
|
|
if err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
for index := range stored {
|
|
if stored[index].ID == record.Metadata.ID {
|
|
stored[index].Raw = data
|
|
stored[index].UpdatedAt = time.Now().UTC()
|
|
}
|
|
}
|
|
if err := c.writeRegistry(stored); err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
if err := c.reload(); err != nil {
|
|
return PluginView{}, err
|
|
}
|
|
for _, item := range c.list() {
|
|
if item.Manifest.ID == manifest.Metadata.ID {
|
|
return item, nil
|
|
}
|
|
}
|
|
return PluginView{}, errors.New("插件状态更新后未加载")
|
|
}
|
|
|
|
func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) PluginManifestView {
|
|
var manifest protocol.Manifest
|
|
_ = json.Unmarshal(raw, &manifest)
|
|
if source == "bundled" {
|
|
if adapter, ok := protocol.Builtins().Get(metadata.ID); ok {
|
|
current := adapter.Metadata()
|
|
current.Enabled = current.Enabled && metadata.Enabled
|
|
metadata = current
|
|
}
|
|
protocol.AttachDocumentation(&metadata)
|
|
}
|
|
if manifest.Metadata.ID == "" {
|
|
manifest.Metadata = metadata
|
|
}
|
|
return PluginManifestView{
|
|
ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "yingce.plugin/v1", Entry: manifest.Entry, Surfaces: manifest.Surfaces,
|
|
Description: metadata.Description, Documentation: metadata.Documentation, Author: metadata.Vendor,
|
|
Permissions: manifest.Permissions, Trusted: source == "bundled", Runtime: manifest.Runtime,
|
|
Configuration: manifest.Configuration, Contributes: manifest.Contributes,
|
|
}
|
|
}
|
|
|
|
func (c *pluginRuntime) uninstall(id string) error {
|
|
c.mutationMu.Lock()
|
|
defer c.mutationMu.Unlock()
|
|
c.mu.RLock()
|
|
record, ok := c.plugins[strings.TrimSpace(id)]
|
|
c.mu.RUnlock()
|
|
if !ok {
|
|
return fmt.Errorf("插件 %q 不存在", id)
|
|
}
|
|
if record.Source == "bundled" {
|
|
return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id)
|
|
}
|
|
stored, err := c.readRegistry()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filtered := stored[:0]
|
|
for _, item := range stored {
|
|
if item.ID != record.Metadata.ID {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
if err := c.writeRegistry(filtered); err != nil {
|
|
return err
|
|
}
|
|
if err := c.reload(); err != nil {
|
|
return err
|
|
}
|
|
if record.PackagePath != "" {
|
|
_ = os.Remove(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *pluginRuntime) readRegistry() ([]pluginRegistryRecord, error) {
|
|
data, err := os.ReadFile(c.registryPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []pluginRegistryRecord{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > protocolPluginMaxBytes*64 {
|
|
return nil, fmt.Errorf("插件 registry 超过大小限制")
|
|
}
|
|
var records []pluginRegistryRecord
|
|
if err := json.Unmarshal(data, &records); err != nil {
|
|
return nil, fmt.Errorf("读取插件 registry 失败:%w", err)
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error {
|
|
data, err := json.MarshalIndent(records, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writePluginFile(c.registryPath, data)
|
|
}
|
|
|
|
func writePluginFile(path string, data []byte) error {
|
|
temporary, err := os.CreateTemp(filepath.Dir(path), ".plugin-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if _, err := temporary.Write(data); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temporaryPath, path)
|
|
}
|
|
|
|
func pluginSource(metadata protocol.Metadata) string {
|
|
if _, ok := protocol.Builtins().Get(metadata.ID); ok {
|
|
return "bundled"
|
|
}
|
|
return "uploaded"
|
|
}
|
|
|
|
func pluginHash(data []byte) string {
|
|
digest := sha256.Sum256(data)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|