1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-07 22:18:30 -04:00
Files
DankMaterialShell/core/internal/plugins/registry.go
T
Artrix b7ec57a534 feat(plugins): add portable plugin lockfile (#2976)
* feat(plugins): add portable plugin lockfile

* plugins/lockfile: allow ssh remotes

---------

Co-authored-by: bbedward <bbedward@gmail.com>
2026-08-07 10:55:57 -04:00

358 lines
8.9 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/go-git/go-git/v6/plumbing"
"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)
CurrentRevision(path string) (string, error)
CheckoutRevision(path string, revision string) 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
}
func (g *realGitClient) CurrentRevision(path string) (string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return "", err
}
head, err := repo.Head()
if err != nil {
return "", err
}
return head.Hash().String(), nil
}
func (g *realGitClient) CheckoutRevision(path string, revision string) error {
if !gitCommitPattern.MatchString(revision) {
return fmt.Errorf("invalid git revision %q", revision)
}
repo, err := git.PlainOpen(path)
if err != nil {
return err
}
hash := plumbing.NewHash(revision)
if _, objectErr := repo.CommitObject(hash); objectErr != nil {
if _, remoteErr := repo.Remote("origin"); remoteErr != nil {
return fmt.Errorf("revision %s is not available in repository: %w", revision, objectErr)
}
fetchErr := repo.Fetch(&git.FetchOptions{})
if fetchErr != nil && fetchErr != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to fetch locked revision: %w", fetchErr)
}
}
if _, err := repo.CommitObject(hash); err != nil {
return fmt.Errorf("revision %s is not available in repository: %w", revision, err)
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
return worktree.Reset(&git.ResetOptions{Commit: hash, Mode: git.HardReset})
}
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)
}