Files
ttstd-infinite-canvas/backend/internal/app/protocol_plugins.go
ddCat bb2a79d365 feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件
新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 23:32:11 +08:00

808 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/protocol"
)
const (
protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
paymentRuntimeReadyFile = ".ready"
)
// 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
paymentRegistry *payment.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) {
dataDir = strings.TrimSpace(dataDir)
if dataDir == "" {
return nil, errors.New("plugin data directory is empty")
}
var err error
dataDir, err = filepath.Abs(dataDir)
if err != nil {
return nil, fmt.Errorf("plugin data directory is invalid: %w", err)
}
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.bootstrapBuiltInPlugins(); err != nil {
return nil, err
}
if err := center.reload(); err != nil {
return nil, err
}
return center, nil
}
func (c *pluginRuntime) bootstrapBuiltInPlugins() 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
}
officialDir, err := officialPluginPackageDir()
if err != nil {
return err
}
entries, err := os.ReadDir(officialDir)
if err != nil {
return fmt.Errorf("读取官方插件目录失败:%w", err)
}
builtInIDs := make(map[string]struct{}, len(entries)+2)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".yingce-plugin") {
continue
}
packageData, err := os.ReadFile(filepath.Join(officialDir, entry.Name()))
if err != nil {
return fmt.Errorf("读取官方插件包 %s:%w", entry.Name(), err)
}
pkg, err := protocol.ParsePluginPackage(packageData)
if err != nil {
return fmt.Errorf("校验官方插件包 %s:%w", entry.Name(), err)
}
if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") {
return fmt.Errorf("官方插件 %q 不能依赖 host 执行器", pkg.Manifest.Metadata.ID)
}
if len(pkg.Manifest.Contributes.PaymentProviders) == 0 {
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err)
}
}
id := pkg.Manifest.Metadata.ID
if _, duplicate := builtInIDs[id]; duplicate {
return fmt.Errorf("官方插件 ID %q 重复", id)
}
builtInIDs[id] = struct{}{}
manifest := pkg.Manifest
record := byID[id]
if len(record.Raw) > 0 {
var previous protocol.Manifest
if err := json.Unmarshal(record.Raw, &previous); err == nil {
manifest.Metadata.Enabled = previous.Metadata.Enabled
}
}
manifestData, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("编码官方插件 %q:%w", id, err)
}
hash := pluginHash(packageData)
packageName := hash + ".yingce-plugin"
if err := writePluginFile(filepath.Join(c.packageDir, packageName), packageData); err != nil {
return fmt.Errorf("缓存官方插件 %q:%w", id, err)
}
now := time.Now().UTC()
if record.InstalledAt.IsZero() {
record.InstalledAt = now
}
source := PluginOriginOfficial
if _, systemPayment := systemPaymentPolicies[id]; systemPayment {
source = PluginOriginSystem
}
record.ID, record.Raw, record.Source, record.FileName = id, manifestData, source, entry.Name()
record.PackagePath, record.PackageSHA256, record.UpdatedAt = packageName, hash, now
byID[id] = record
}
bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...)
for _, bundled := range bundledManifests {
builtInIDs[bundled.Metadata.ID] = struct{}{}
if existing, exists := byID[bundled.Metadata.ID]; exists && existing.PackagePath != "" && isSystemPaymentPluginID(bundled.Metadata.ID) {
// An official package is the executable source of truth. The bundled
// manifest remains only as a fallback when the package is unavailable.
continue
}
data, err := json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
record := byID[bundled.Metadata.ID]
if len(record.Raw) > 0 {
var installed protocol.Manifest
if err := json.Unmarshal(record.Raw, &installed); err != nil {
return fmt.Errorf("decode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
bundled.Metadata.Enabled = installed.Metadata.Enabled
data, err = json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
}
now := time.Now().UTC()
if record.InstalledAt.IsZero() {
record.InstalledAt = now
}
source := PluginOriginOfficial
if _, systemPayment := systemPaymentPolicies[bundled.Metadata.ID]; systemPayment {
source = PluginOriginSystem
}
record.UpdatedAt = now
record.ID, record.Raw, record.Source, record.PackagePath = bundled.Metadata.ID, data, source, ""
byID[bundled.Metadata.ID] = record
}
result := make([]pluginRegistryRecord, 0, len(byID))
for _, record := range byID {
if isBuiltInPluginSource(record.Source) {
if _, exists := builtInIDs[record.ID]; !exists {
// Built-in records are reconciled from repository packages and host
// manifests on every startup, so removed plugins cannot survive stale.
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 isSystemPaymentPluginID(id string) bool {
_, ok := systemPaymentPolicies[strings.TrimSpace(id)]
return ok
}
func isBuiltInPluginSource(source string) bool {
switch strings.TrimSpace(source) {
case "bundled", PluginOriginOfficial, PluginOriginSystem:
return true
default:
return false
}
}
func officialPluginPackageDir() (string, error) {
if configured := strings.TrimSpace(os.Getenv("CANVAS_OFFICIAL_PLUGIN_DIR")); configured != "" {
info, err := os.Stat(configured)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("CANVAS_OFFICIAL_PLUGIN_DIR 不是可读目录:%s", configured)
}
return configured, nil
}
workingDir, err := os.Getwd()
if err != nil {
return "", err
}
candidates := []string{"/app/plugin-packages"}
current := workingDir
for range 8 {
candidates = append(candidates, filepath.Join(current, "plugin-packages"))
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate, nil
}
}
return "", errors.New("未找到官方 plugin-packages 目录;请设置 CANVAS_OFFICIAL_PLUGIN_DIR")
}
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 == "" && strings.TrimSpace(storedRecord.PackagePath) == "" {
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 {
var manifest protocol.Manifest
if err := json.Unmarshal(record.Raw, &manifest); err != nil {
record.Error = err.Error()
plugins[id] = record
continue
}
if len(manifest.Contributes.PaymentProviders) > 0 && record.Source == PluginOriginUploaded {
backend := strings.TrimSpace(manifest.Runtime.Backend)
if strings.HasPrefix(backend, "host:") {
record.Metadata.Enabled = false
record.Status = "invalid"
record.Error = "首期支付插件只能使用系统宿主适配器"
plugins[id] = record
continue
}
if backend != "rpc" && backend != "wasm" {
record.Metadata.Enabled = false
record.Status = "invalid"
record.Error = "上传支付插件必须声明 rpc 或 wasm 后端"
plugins[id] = record
continue
}
}
if len(manifest.Contributes.PaymentProviders) > 0 && len(manifest.Contributes.Providers) == 0 {
if record.Metadata.Enabled {
record.Status = "enabled"
} else {
record.Status = "disabled"
}
plugins[id] = record
continue
}
adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, nil)
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
// Payment adapters are loaded exclusively from validated plugin packages.
// An empty registry is intentional when the official package is unavailable;
// payment writes must fail closed instead of silently using host code.
basePayment, registryErr := payment.NewRegistry()
if registryErr != nil {
return registryErr
}
dynamicPayments := make([]payment.Provider, 0)
for id, record := range plugins {
if (record.Source != PluginOriginUploaded && record.Source != PluginOriginOfficial && record.Source != PluginOriginSystem) || !record.Metadata.Enabled || record.Status != "enabled" {
continue
}
var manifest protocol.Manifest
if err := json.Unmarshal(record.Raw, &manifest); err != nil || len(manifest.Contributes.PaymentProviders) == 0 {
continue
}
if strings.TrimSpace(manifest.Runtime.Backend) != "rpc" {
record.Status = "invalid"
record.Error = "wasm 支付运行时尚未启用"
plugins[id] = record
continue
}
packageData, readErr := os.ReadFile(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
if readErr != nil {
record.Status = "invalid"
record.Error = "支付插件包文件不存在"
plugins[id] = record
continue
}
packageSHA256 := pluginHash(packageData)
if expected := strings.TrimSpace(record.PackageSHA256); expected != "" && !strings.EqualFold(expected, packageSHA256) {
record.Status = "invalid"
record.Error = "支付插件包完整性校验失败"
plugins[id] = record
continue
}
record.PackageSHA256 = packageSHA256
record.SHA256 = packageSHA256
plugins[id] = record
pkg, parseErr := protocol.ParsePluginPackage(packageData)
if parseErr != nil {
record.Status = "invalid"
record.Error = parseErr.Error()
plugins[id] = record
continue
}
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, packageSHA256, pkg)
if materializeErr != nil {
record.Status = "invalid"
record.Error = materializeErr.Error()
plugins[id] = record
continue
}
for _, contribution := range manifest.Contributes.PaymentProviders {
provider, providerErr := payment.NewRPCProvider(payment.DescriptorFromManifest(manifest, contribution), runtimeDir, manifest.Runtime.BackendEntry)
if providerErr != nil {
record.Status = "invalid"
record.Error = providerErr.Error()
continue
}
dynamicPayments = append(dynamicPayments, provider)
}
}
dynamicIDs := make(map[string]struct{}, len(dynamicPayments))
for _, provider := range dynamicPayments {
dynamicIDs[provider.Descriptor().ID] = struct{}{}
}
baseProviders := make([]payment.Provider, 0)
for _, provider := range basePayment.Providers() {
if _, replaced := dynamicIDs[provider.Descriptor().ID]; !replaced {
baseProviders = append(baseProviders, provider)
}
}
providers := append(baseProviders, dynamicPayments...)
c.paymentRegistry, err = payment.NewRegistry(providers...)
if err != nil {
return err
}
c.plugins = plugins
return nil
}
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
digest := strings.ToLower(strings.TrimSpace(hash))
if len(digest) != sha256.Size*2 {
return "", errors.New("支付插件包摘要无效")
}
if _, err := hex.DecodeString(digest); err != nil {
return "", errors.New("支付插件包摘要无效")
}
runtimeRoot := filepath.Join(packageDir, "runtime")
root := filepath.Join(runtimeRoot, digest)
backendEntry := strings.TrimSpace(pkg.Manifest.Runtime.BackendEntry)
if paymentRuntimeReady(root, digest, backendEntry) {
return root, nil
}
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
return "", fmt.Errorf("创建支付插件运行目录失败:%w", err)
}
temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-")
if err != nil {
return "", fmt.Errorf("创建支付插件临时运行目录失败:%w", err)
}
defer os.RemoveAll(temporaryRoot)
for name, content := range pkg.Files {
if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
continue
}
target := filepath.Join(temporaryRoot, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return "", err
}
if err := os.WriteFile(target, content, 0o700); err != nil {
return "", fmt.Errorf("写入支付插件运行文件失败:%w", err)
}
}
if err := os.WriteFile(filepath.Join(temporaryRoot, paymentRuntimeReadyFile), []byte(digest+"\n"), 0o600); err != nil {
return "", fmt.Errorf("写入支付插件运行状态失败:%w", err)
}
if err := os.Rename(temporaryRoot, root); err != nil {
if paymentRuntimeReady(root, digest, backendEntry) {
return root, nil
}
// Digest directories are immutable once ready. An unmarked directory can
// only be residue from an interrupted extraction and is safe to replace.
if removeErr := os.RemoveAll(root); removeErr != nil {
return "", fmt.Errorf("清理未完成的支付插件运行目录失败:%w", removeErr)
}
if renameErr := os.Rename(temporaryRoot, root); renameErr != nil {
return "", fmt.Errorf("发布支付插件运行目录失败:%w", renameErr)
}
}
return root, nil
}
func paymentRuntimeReady(root, digest, backendEntry string) bool {
ready, err := os.ReadFile(filepath.Join(root, paymentRuntimeReadyFile))
if err != nil || strings.TrimSpace(string(ready)) != digest {
return false
}
return protocol.HasAnyPaymentRPCBackend(backendEntry, paymentRuntimeBackendFiles(root))
}
func paymentRuntimeBackendFiles(root string) map[string][]byte {
files := map[string][]byte{}
backendRoot := filepath.Join(root, "backend")
_ = filepath.WalkDir(backendRoot, func(path string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
files[filepath.ToSlash(rel)] = []byte{1}
return nil
})
return files
}
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) paymentRegistrySnapshot() *payment.Registry {
c.mu.RLock()
defer c.mu.RUnlock()
return c.paymentRegistry
}
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 len(manifest.Contributes.PaymentProviders) == 0 {
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 && isBuiltInPluginSource(existing.Source) && !isPaymentPluginManifest(manifest) {
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 isPaymentPluginManifest(manifest protocol.Manifest) bool {
backend := strings.TrimSpace(manifest.Runtime.Backend)
return len(manifest.Contributes.PaymentProviders) > 0 && (backend == "rpc" || backend == "wasm")
}
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 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: isBuiltInPluginSource(source), 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 isBuiltInPluginSource(record.Source) {
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 {
return "uploaded"
}
func pluginHash(data []byte) string {
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:])
}