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) }