mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-07 05:58:28 -04:00
3f6cd0b579
feat(registry): support multiple plugin/theme registries closes #2763 --------- Co-authored-by: bbedward <bbedward@gmail.com>
314 lines
7.6 KiB
Go
314 lines
7.6 KiB
Go
package plugins
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
|
"github.com/go-git/go-git/v6"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
type Plugin struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Category string `json:"category"`
|
|
Repo string `json:"repo"`
|
|
Path string `json:"path,omitempty"`
|
|
Author string `json:"author"`
|
|
Description string `json:"description"`
|
|
Dependencies []string `json:"dependencies,omitempty"`
|
|
Compositors []string `json:"compositors"`
|
|
Distro []string `json:"distro"`
|
|
Screenshot string `json:"screenshot,omitempty"`
|
|
RequiresDMS string `json:"requires_dms,omitempty"`
|
|
Featured bool `json:"featured,omitempty"`
|
|
}
|
|
|
|
type GitClient interface {
|
|
PlainClone(path string, url string) error
|
|
Pull(path string) error
|
|
OriginURL(path string) (string, error)
|
|
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
|
}
|
|
|
|
type realGitClient struct{}
|
|
|
|
func (g *realGitClient) PlainClone(path string, url string) error {
|
|
_, err := git.PlainClone(path, &git.CloneOptions{
|
|
URL: url,
|
|
Progress: os.Stdout,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (g *realGitClient) Pull(path string) error {
|
|
repo, err := git.PlainOpen(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = worktree.Pull(&git.PullOptions{})
|
|
if err != nil && err.Error() != "already up-to-date" {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *realGitClient) OriginURL(path string) (string, error) {
|
|
repo, err := git.PlainOpen(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
remote, err := repo.Remote("origin")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
urls := remote.Config().URLs
|
|
if len(urls) == 0 {
|
|
return "", errors.New("origin remote has no URL")
|
|
}
|
|
return urls[0], nil
|
|
}
|
|
|
|
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
|
repo, err := git.PlainOpen(path)
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
|
|
// Fetch remote changes
|
|
err = repo.Fetch(&git.FetchOptions{})
|
|
if err != nil && err.Error() != "already up-to-date" {
|
|
// If fetch fails, we can't determine if there are updates
|
|
// Return false and the error
|
|
return false, "", "", err
|
|
}
|
|
|
|
// Get the HEAD reference
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
|
|
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
|
|
remote, err := repo.Remote("origin")
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
|
|
refs, err := remote.List(&git.ListOptions{})
|
|
if err != nil {
|
|
return false, "", "", err
|
|
}
|
|
|
|
// Find the default branch remote ref
|
|
var remoteHead string
|
|
for _, ref := range refs {
|
|
if ref.Name().IsBranch() {
|
|
// Try common branch names
|
|
if ref.Name().Short() == "main" || ref.Name().Short() == "master" {
|
|
remoteHead = ref.Hash().String()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
localHash := head.Hash().String()
|
|
// If we couldn't find a remote HEAD, assume no updates
|
|
if remoteHead == "" {
|
|
return false, localHash, "", nil
|
|
}
|
|
|
|
// Compare local HEAD with remote HEAD
|
|
return localHash != remoteHead, localHash, remoteHead, nil
|
|
}
|
|
|
|
type Registry struct {
|
|
fs afero.Fs
|
|
cacheDir string
|
|
registries []registries.Source
|
|
plugins []Plugin
|
|
git GitClient
|
|
}
|
|
|
|
func NewRegistry() (*Registry, error) {
|
|
return NewRegistryWithFs(afero.NewOsFs())
|
|
}
|
|
|
|
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
|
return &Registry{
|
|
fs: fs,
|
|
cacheDir: getCacheDir(),
|
|
registries: registries.Load(fs),
|
|
git: &realGitClient{},
|
|
}, nil
|
|
}
|
|
|
|
func (r *Registry) cacheDirFor(src registries.Source) string {
|
|
return filepath.Join(r.cacheDir, src.Name)
|
|
}
|
|
|
|
func getCacheDir() string {
|
|
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
|
}
|
|
|
|
// A cached clone is reused only when its origin still matches the configured
|
|
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
|
// stale remote.
|
|
func (r *Registry) updateOne(src registries.Source) error {
|
|
dir := r.cacheDirFor(src)
|
|
exists, err := afero.DirExists(r.fs, dir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check cache directory: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
origin, originErr := r.git.OriginURL(dir)
|
|
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
|
return nil
|
|
}
|
|
if err := r.fs.RemoveAll(dir); err != nil {
|
|
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
|
}
|
|
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
|
return fmt.Errorf("failed to clone: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// A registry without a plugins/ directory is a valid themes-only registry.
|
|
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
|
pluginsDir := filepath.Join(dir, "plugins")
|
|
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
|
}
|
|
|
|
var plugins []Plugin
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
continue
|
|
}
|
|
|
|
data, err := afero.ReadFile(r.fs, filepath.Join(pluginsDir, entry.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var plugin Plugin
|
|
if err := json.Unmarshal(data, &plugin); err != nil {
|
|
continue
|
|
}
|
|
|
|
if plugin.ID == "" {
|
|
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
|
}
|
|
|
|
plugins = append(plugins, plugin)
|
|
}
|
|
return plugins, nil
|
|
}
|
|
|
|
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
|
// layout nests under it, so a leftover clone is deleted wholesale first.
|
|
func (r *Registry) resetLegacyCache() {
|
|
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
|
_ = r.fs.RemoveAll(r.cacheDir)
|
|
}
|
|
}
|
|
|
|
// Update refreshes every configured registry, aggregating plugins in
|
|
// declaration order (first occurrence of an ID wins). A failing registry is
|
|
// reported in the joined error but does not block the others.
|
|
func (r *Registry) Update() error {
|
|
r.resetLegacyCache()
|
|
r.plugins = []Plugin{}
|
|
seen := make(map[string]struct{})
|
|
var errs []error
|
|
for _, src := range r.registries {
|
|
if err := r.updateOne(src); err != nil {
|
|
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
continue
|
|
}
|
|
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
|
continue
|
|
}
|
|
for _, p := range plugins {
|
|
if _, dup := seen[p.ID]; dup {
|
|
continue
|
|
}
|
|
seen[p.ID] = struct{}{}
|
|
r.plugins = append(r.plugins, p)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (r *Registry) List() ([]Plugin, error) {
|
|
if len(r.plugins) == 0 {
|
|
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return SortByFirstParty(r.plugins), nil
|
|
}
|
|
|
|
func (r *Registry) Search(query string) ([]Plugin, error) {
|
|
allPlugins, err := r.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if query == "" {
|
|
return allPlugins, nil
|
|
}
|
|
|
|
return SortByFirstParty(FuzzySearch(query, allPlugins)), nil
|
|
}
|
|
|
|
func (r *Registry) Get(idOrName string) (*Plugin, error) {
|
|
plugins, err := r.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First, try to find by ID (preferred method)
|
|
for _, p := range plugins {
|
|
if p.ID == idOrName {
|
|
return &p, nil
|
|
}
|
|
}
|
|
|
|
// Fallback to name for backward compatibility
|
|
for _, p := range plugins {
|
|
if p.Name == idOrName {
|
|
return &p, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("plugin not found: %s", idOrName)
|
|
}
|