Files
2026-07-13 12:35:43 +08:00

603 lines
17 KiB
Go

package homeplugins
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
"gopkg.in/yaml.v3"
)
type Platform struct {
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
}
type PluginRuntime interface {
PluginBusy(id string) bool
UnloadPlugin(id string) bool
}
type PluginLoadInspector interface {
PluginRegistered(id string) bool
}
type SyncReport struct {
SchemaVersion int `json:"schema_version"`
TaskID uint `json:"task_id,omitempty"`
Task string `json:"task"`
NodeID string `json:"node_id,omitempty"`
Status string `json:"status"`
Phase string `json:"phase"`
OK bool `json:"ok"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Platform Platform `json:"platform"`
Plugins []PluginInstallStatus `json:"plugins"`
Error string `json:"error,omitempty"`
}
type PluginInstallStatus struct {
ID string `json:"id"`
Version string `json:"version,omitempty"`
ReleaseTag string `json:"release_tag,omitempty"`
Repository string `json:"repository,omitempty"`
InstallType string `json:"install_type,omitempty"`
InstallStatus string `json:"install_status"`
LoadStatus string `json:"load_status,omitempty"`
Path string `json:"path,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Overwritten bool `json:"overwritten,omitempty"`
Error string `json:"error,omitempty"`
}
const (
pluginTaskName = "plugin-sync"
pluginDeleteTaskName = "plugin-delete"
pluginTaskStatusOK = "success"
pluginTaskStatusError = "failed"
pluginTaskPhaseInstall = "install"
pluginTaskPhaseLoad = "load"
pluginTaskPhaseDelete = "delete"
pluginInstallStatusInstalled = "installed"
pluginInstallStatusSkipped = "skipped"
pluginInstallStatusFailed = "failed"
pluginInstallStatusDeleted = "deleted"
pluginInstallStatusMissing = "missing"
pluginLoadStatusLoaded = "loaded"
pluginLoadStatusFailed = "failed"
)
// CurrentPlatform reports the platform used by pluginhost discovery.
func CurrentPlatform() Platform {
return Platform{
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
}
}
func NormalizePlatform(platform Platform) Platform {
goos := strings.ToLower(strings.TrimSpace(platform.GOOS))
switch goos {
case "mac", "macos", "osx":
goos = "darwin"
}
goarch := strings.ToLower(strings.TrimSpace(platform.GOARCH))
switch goarch {
case "x64", "x86_64":
goarch = "amd64"
case "aarch64":
goarch = "arm64"
}
return Platform{GOOS: goos, GOARCH: goarch}
}
func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error {
_, errSync := SyncPlatformWithReport(ctx, cfg, pluginRuntime, CurrentPlatform())
return errSync
}
func SyncPlatform(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) error {
_, errSync := SyncPlatformWithReport(ctx, cfg, pluginRuntime, platform)
return errSync
}
func SyncWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) (SyncReport, error) {
return SyncPlatformWithReport(ctx, cfg, pluginRuntime, CurrentPlatform())
}
func SyncPlatformWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) (SyncReport, error) {
if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
return newSyncReport(platform), nil
}
platform = NormalizePlatform(platform)
report := newSyncReport(platform)
if platform.GOOS == "" {
errPlatform := fmt.Errorf("home plugins: goos is required")
finishReport(&report, errPlatform)
return report, errPlatform
}
if platform.GOARCH == "" {
errPlatform := fmt.Errorf("home plugins: goarch is required")
finishReport(&report, errPlatform)
return report, errPlatform
}
report.Platform = platform
root := strings.TrimSpace(cfg.Plugins.Dir)
if root == "" {
root = "plugins"
}
client := newPluginStoreClient(cfg)
var syncErrors []error
ids := make([]string, 0, len(cfg.Plugins.Configs))
for id := range cfg.Plugins.Configs {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
item := cfg.Plugins.Configs[id]
if !pluginConfigEnabled(item) {
continue
}
manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item)
if errManifest != nil {
status := PluginInstallStatus{
ID: strings.TrimSpace(id),
InstallStatus: pluginInstallStatusFailed,
Error: errManifest.Error(),
}
report.Plugins = append(report.Plugins, status)
syncErrors = append(syncErrors, errManifest)
continue
}
if !okManifest {
continue
}
status := pluginStatusFromManifest(manifest)
result, errSync := installManifest(ctx, client, manifest, root, platform, pluginRuntime)
if errSync != nil {
status.InstallStatus = pluginInstallStatusFailed
status.Error = errSync.Error()
report.Plugins = append(report.Plugins, status)
syncErrors = append(syncErrors, errSync)
continue
}
status.Path = strings.TrimSpace(result.Path)
status.Skipped = result.Skipped
status.Overwritten = result.Overwritten
if result.Skipped {
status.InstallStatus = pluginInstallStatusSkipped
} else {
status.InstallStatus = pluginInstallStatusInstalled
}
report.Plugins = append(report.Plugins, status)
}
errSync := errors.Join(syncErrors...)
finishReport(&report, errSync)
return report, errSync
}
func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest sdkpluginstore.Manifest, root string, platform Platform, pluginRuntime PluginRuntime) (sdkpluginstore.InstallResult, error) {
id := strings.TrimSpace(manifest.ID)
if id == "" {
return sdkpluginstore.InstallResult{}, fmt.Errorf("home plugins: manifest plugin id is empty")
}
pluginIsBusy := func() bool {
return pluginRuntime != nil && pluginRuntime.PluginBusy(id)
}
result, errInstall := client.InstallManifest(ctx, manifest, sdkpluginstore.InstallOptions{
PluginsDir: root,
GOOS: platform.GOOS,
GOARCH: platform.GOARCH,
PluginLoaded: pluginIsBusy,
})
if errInstall != nil {
return sdkpluginstore.InstallResult{}, fmt.Errorf("home plugins: install %s: %w", id, errInstall)
}
return result, nil
}
func DeleteWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, taskID uint, pluginID string) SyncReport {
_ = ctx
platform := CurrentPlatform()
report := newSyncReport(platform)
report.TaskID = taskID
report.Task = pluginDeleteTaskName
report.Phase = pluginTaskPhaseDelete
pluginID = strings.TrimSpace(pluginID)
status := PluginInstallStatus{ID: pluginID}
if cfg == nil {
status.InstallStatus = pluginInstallStatusFailed
status.Error = "home plugins: config is nil"
report.Plugins = append(report.Plugins, status)
finishReport(&report, errors.New(status.Error))
return report
}
root := strings.TrimSpace(cfg.Plugins.Dir)
if root == "" {
root = "plugins"
}
path, deleted, errDelete := deletePluginArtifact(root, pluginID, pluginRuntime)
status.Path = strings.TrimSpace(path)
switch {
case errDelete != nil:
status.InstallStatus = pluginInstallStatusFailed
status.Error = errDelete.Error()
case deleted:
status.InstallStatus = pluginInstallStatusDeleted
default:
status.InstallStatus = pluginInstallStatusMissing
}
report.Plugins = append(report.Plugins, status)
finishReport(&report, errDelete)
return report
}
func deletePluginArtifact(root string, id string, pluginRuntime PluginRuntime) (string, bool, error) {
id = strings.TrimSpace(id)
if !validPluginFileID(id) {
return "", false, fmt.Errorf("invalid plugin id %q", id)
}
paths, errPaths := pluginFilePaths(root, id)
if errPaths != nil {
return "", false, errPaths
}
if len(paths) == 0 {
return "", false, nil
}
if pluginRuntime != nil && pluginRuntime.PluginBusy(id) {
if !pluginRuntime.UnloadPlugin(id) && pluginRuntime.PluginBusy(id) {
return paths[0], false, sdkpluginstore.ErrLoadedPluginLocked
}
}
deleted := false
for _, path := range paths {
if errRemove := os.Remove(path); errRemove != nil {
if errors.Is(errRemove, os.ErrNotExist) {
continue
}
return paths[0], deleted, errRemove
}
deleted = true
}
return paths[0], deleted, nil
}
func currentPluginFilePath(root string, id string) (string, error) {
paths, errPaths := pluginFilePaths(root, id)
if errPaths != nil {
return "", errPaths
}
if len(paths) == 0 {
return "", nil
}
return paths[0], nil
}
func pluginFilePaths(root string, id string) ([]string, error) {
files, errFiles := pluginFileInfos(root, id)
if errFiles != nil {
return nil, errFiles
}
out := make([]string, 0, len(files))
for _, file := range files {
out = append(out, file.Path)
}
return out, nil
}
func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) {
root = strings.TrimSpace(root)
if root == "" {
root = "plugins"
}
id = strings.TrimSpace(id)
platform := CurrentPlatform()
extension := pluginExtension(platform.GOOS)
candidates := make([]pluginFileInfo, 0)
for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH) {
entries, errReadDir := os.ReadDir(dir)
if errReadDir != nil {
if errors.Is(errReadDir, os.ErrNotExist) {
continue
}
return nil, errReadDir
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry == nil || !entry.Type().IsRegular() {
continue
}
if strings.HasSuffix(strings.ToLower(entry.Name()), extension) {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
sort.Strings(files)
for _, filePath := range files {
file, okFile := pluginFileFromPath(filePath, extension)
if !okFile || file.ID != id {
continue
}
candidates = append(candidates, file)
}
}
if len(candidates) <= 1 {
return candidates, nil
}
bestIndex := 0
for index := 1; index < len(candidates); index++ {
if pluginFilePreferred(candidates[index], candidates[bestIndex]) {
bestIndex = index
}
}
if bestIndex == 0 {
return candidates, nil
}
out := make([]pluginFileInfo, 0, len(candidates))
out = append(out, candidates[bestIndex])
for index, candidate := range candidates {
if index == bestIndex {
continue
}
out = append(out, candidate)
}
return out, nil
}
type pluginFileInfo struct {
ID string
Path string
Version string
}
func pluginCandidateDirs(root string, goos string, goarch string) []string {
dirs := make([]string, 0, 2)
dirs = append(dirs, filepath.Join(root, goos, goarch))
dirs = append(dirs, root)
return dirs
}
func pluginIDFromPath(path string) string {
file, ok := pluginFileFromPath(path, "")
if ok {
return file.ID
}
base := filepath.Base(path)
lowerBase := strings.ToLower(base)
for _, extension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, extension) {
return base[:len(base)-len(extension)]
}
}
return base
}
func pluginFileFromPath(filePath string, requiredExtension string) (pluginFileInfo, bool) {
base := filepath.Base(filePath)
lowerBase := strings.ToLower(base)
extension := strings.TrimSpace(requiredExtension)
if extension != "" {
if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) {
return pluginFileInfo{}, false
}
} else {
for _, candidateExtension := range []string{".so", ".dylib", ".dll"} {
if strings.HasSuffix(lowerBase, candidateExtension) {
extension = candidateExtension
break
}
}
if extension == "" {
return pluginFileInfo{}, false
}
}
name := base[:len(base)-len(extension)]
id := name
version := ""
if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 {
candidateID := name[:versionIndex]
candidateVersion := name[versionIndex+2:]
if validPluginFileID(candidateID) && validPluginFileVersion(candidateVersion) {
id = candidateID
version = candidateVersion
}
}
if !validPluginFileID(id) {
return pluginFileInfo{}, false
}
return pluginFileInfo{ID: id, Path: filePath, Version: version}, true
}
func pluginFilePreferred(candidate pluginFileInfo, current pluginFileInfo) bool {
if strings.TrimSpace(current.Path) == "" {
return true
}
if candidate.Version == "" {
return false
}
if current.Version == "" {
return true
}
return sdkpluginstore.UpdateAvailable(current.Version, candidate.Version)
}
func pluginExtension(goos string) string {
switch strings.ToLower(strings.TrimSpace(goos)) {
case "darwin", "mac", "macos", "osx":
return ".dylib"
case "windows":
return ".dll"
default:
return ".so"
}
}
func validPluginFileID(id string) bool {
id = strings.TrimSpace(id)
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
return false
}
for _, char := range id {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '-', char == '_', char == '.':
default:
return false
}
}
return true
}
func validPluginFileVersion(version string) bool {
version = strings.TrimSpace(version)
if version == "" || strings.HasPrefix(version, "v") {
return false
}
first := version[0]
return first >= '0' && first <= '9'
}
func MarkLoadResults(report *SyncReport, inspector PluginLoadInspector) error {
if report == nil {
return nil
}
report.Phase = pluginTaskPhaseLoad
var loadErrors []error
for index := range report.Plugins {
status := &report.Plugins[index]
if status.InstallStatus == pluginInstallStatusFailed {
if status.LoadStatus == "" {
status.LoadStatus = pluginInstallStatusSkipped
}
if strings.TrimSpace(status.Error) != "" {
loadErrors = append(loadErrors, errors.New(status.Error))
} else {
loadErrors = append(loadErrors, fmt.Errorf("home plugins: plugin %s install failed", status.ID))
}
continue
}
if inspector != nil && inspector.PluginRegistered(status.ID) {
status.LoadStatus = pluginLoadStatusLoaded
continue
}
status.LoadStatus = pluginLoadStatusFailed
errLoad := fmt.Errorf("home plugins: plugin %s installed but not loaded", status.ID)
if strings.TrimSpace(status.Error) == "" {
status.Error = errLoad.Error()
}
loadErrors = append(loadErrors, errLoad)
}
errLoad := errors.Join(loadErrors...)
finishReport(report, errLoad)
return errLoad
}
func newSyncReport(platform Platform) SyncReport {
now := time.Now().UTC()
return SyncReport{
SchemaVersion: 1,
Task: pluginTaskName,
Status: pluginTaskStatusOK,
Phase: pluginTaskPhaseInstall,
OK: true,
StartedAt: now,
UpdatedAt: now,
Platform: NormalizePlatform(platform),
Plugins: []PluginInstallStatus{},
}
}
func finishReport(report *SyncReport, errTask error) {
if report == nil {
return
}
now := time.Now().UTC()
report.FinishedAt = now
report.UpdatedAt = now
report.OK = errTask == nil
if errTask != nil {
report.Status = pluginTaskStatusError
report.Error = errTask.Error()
return
}
report.Status = pluginTaskStatusOK
report.Error = ""
}
func pluginStatusFromManifest(manifest sdkpluginstore.Manifest) PluginInstallStatus {
return PluginInstallStatus{
ID: strings.TrimSpace(manifest.ID),
Version: strings.TrimSpace(manifest.Version),
ReleaseTag: strings.TrimSpace(manifest.ReleaseTag),
Repository: strings.TrimSpace(manifest.Repository),
InstallType: manifest.InstallType(),
InstallStatus: pluginInstallStatusFailed,
}
}
func storeManifestFromPluginConfig(id string, item config.PluginInstanceConfig) (sdkpluginstore.Manifest, bool, error) {
if item.Raw.Kind == 0 {
return sdkpluginstore.Manifest{}, false, nil
}
storeNode := yamlMappingValue(&item.Raw, "store")
if storeNode == nil || storeNode.Kind == 0 {
return sdkpluginstore.Manifest{}, false, nil
}
var manifest sdkpluginstore.Manifest
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: decode store manifest for %s: %w", id, errDecode)
}
if strings.TrimSpace(manifest.ID) == "" {
manifest.ID = strings.TrimSpace(id)
}
if errValidate := manifest.Validate(); errValidate != nil {
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: invalid store manifest for %s: %w", id, errValidate)
}
return manifest, true, nil
}
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
if node == nil || node.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(node.Content); i += 2 {
keyNode := node.Content[i]
if keyNode == nil || keyNode.Value != key {
continue
}
return node.Content[i+1]
}
return nil
}
var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
client := &http.Client{}
var storeAuth []sdkpluginstore.AuthConfig
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
}
if cfg != nil {
storeAuth = cfg.Plugins.StoreAuth
}
return sdkpluginstore.NewClientWithAuth(client, "", storeAuth)
}
func pluginConfigEnabled(item config.PluginInstanceConfig) bool {
return item.Enabled != nil && *item.Enabled
}