1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-07 22:15:38 -05:00

switch hto monorepo structure

This commit is contained in:
bbedward
2025-11-12 17:18:45 -05:00
parent 6013c994a6
commit 24e800501a
768 changed files with 76284 additions and 221 deletions

View File

@@ -0,0 +1,430 @@
package plugins
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/afero"
)
type Manager struct {
fs afero.Fs
pluginsDir string
gitClient GitClient
}
func NewManager() (*Manager, error) {
return NewManagerWithFs(afero.NewOsFs())
}
func NewManagerWithFs(fs afero.Fs) (*Manager, error) {
pluginsDir := getPluginsDir()
return &Manager{
fs: fs,
pluginsDir: pluginsDir,
gitClient: &realGitClient{},
}, nil
}
func getPluginsDir() string {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), "DankMaterialShell", "plugins")
}
configHome = filepath.Join(homeDir, ".config")
}
return filepath.Join(configHome, "DankMaterialShell", "plugins")
}
func (m *Manager) IsInstalled(plugin Plugin) (bool, error) {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return false, err
}
if exists {
return true, nil
}
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return false, err
}
return systemExists, nil
}
func (m *Manager) Install(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if exists {
return fmt.Errorf("plugin already installed: %s", plugin.Name)
}
if err := m.fs.MkdirAll(m.pluginsDir, 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
reposDir := filepath.Join(m.pluginsDir, ".repos")
if err := m.fs.MkdirAll(reposDir, 0755); err != nil {
return fmt.Errorf("failed to create repos directory: %w", err)
}
if plugin.Path != "" {
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
repoExists, err := afero.DirExists(m.fs, repoPath)
if err != nil {
return fmt.Errorf("failed to check if repo exists: %w", err)
}
if !repoExists {
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
m.fs.RemoveAll(repoPath)
return fmt.Errorf("failed to clone repository: %w", err)
}
} else {
// Pull latest changes if repo already exists
if err := m.gitClient.Pull(repoPath); err != nil {
// If pull fails (e.g., corrupted shallow clone), delete and re-clone
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone repository: %w", err)
}
}
}
sourcePath := filepath.Join(repoPath, plugin.Path)
sourceExists, err := afero.DirExists(m.fs, sourcePath)
if err != nil {
return fmt.Errorf("failed to check plugin path: %w", err)
}
if !sourceExists {
return fmt.Errorf("plugin path does not exist in repository: %s", plugin.Path)
}
if err := m.createSymlink(sourcePath, pluginPath); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
metaPath := pluginPath + ".meta"
metaContent := fmt.Sprintf("repo=%s\npath=%s\nrepodir=%s", plugin.Repo, plugin.Path, repoName)
if err := afero.WriteFile(m.fs, metaPath, []byte(metaContent), 0644); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
} else {
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
m.fs.RemoveAll(pluginPath)
return fmt.Errorf("failed to clone plugin: %w", err)
}
}
return nil
}
func (m *Manager) getRepoName(repoURL string) string {
hash := sha256.Sum256([]byte(repoURL))
return hex.EncodeToString(hash[:])[:16]
}
func (m *Manager) createSymlink(source, dest string) error {
if symlinkFs, ok := m.fs.(afero.Symlinker); ok {
return symlinkFs.SymlinkIfPossible(source, dest)
}
return os.Symlink(source, dest)
}
func (m *Manager) Update(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if systemExists {
return fmt.Errorf("cannot update system plugin: %s", plugin.Name)
}
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := m.gitClient.Pull(repoPath); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to remove corrupted repository: %w", err)
}
if err := m.gitClient.PlainClone(repoPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone repository: %w", err)
}
}
} else {
// Try to pull, if it fails, delete and re-clone
if err := m.gitClient.Pull(pluginPath); err != nil {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return fmt.Errorf("failed to remove corrupted plugin: %w", err)
}
if err := m.gitClient.PlainClone(pluginPath, plugin.Repo); err != nil {
return fmt.Errorf("failed to re-clone plugin: %w", err)
}
}
}
return nil
}
func (m *Manager) Uninstall(plugin Plugin) error {
pluginPath := filepath.Join(m.pluginsDir, plugin.ID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", plugin.ID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return fmt.Errorf("failed to check if plugin exists: %w", err)
}
if systemExists {
return fmt.Errorf("cannot uninstall system plugin: %s", plugin.Name)
}
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
shouldCleanup, err := m.shouldCleanupRepo(repoPath, plugin.Repo, plugin.ID)
if err != nil {
return fmt.Errorf("failed to check repo cleanup: %w", err)
}
if err := m.fs.Remove(pluginPath); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
if err := m.fs.Remove(metaPath); err != nil {
return fmt.Errorf("failed to remove metadata: %w", err)
}
if shouldCleanup {
if err := m.fs.RemoveAll(repoPath); err != nil {
return fmt.Errorf("failed to cleanup repository: %w", err)
}
}
} else {
if err := m.fs.RemoveAll(pluginPath); err != nil {
return fmt.Errorf("failed to remove plugin: %w", err)
}
}
return nil
}
func (m *Manager) shouldCleanupRepo(repoPath, repoURL, excludePlugin string) (bool, error) {
installed, err := m.ListInstalled()
if err != nil {
return false, err
}
registry, err := NewRegistry()
if err != nil {
return false, err
}
allPlugins, err := registry.List()
if err != nil {
return false, err
}
for _, id := range installed {
if id == excludePlugin {
continue
}
for _, p := range allPlugins {
if p.ID == id && p.Repo == repoURL && p.Path != "" {
return false, nil
}
}
}
return true, nil
}
func (m *Manager) ListInstalled() ([]string, error) {
installedMap := make(map[string]bool)
exists, err := afero.DirExists(m.fs, m.pluginsDir)
if err != nil {
return nil, err
}
if exists {
entries, err := afero.ReadDir(m.fs, m.pluginsDir)
if err != nil {
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
}
for _, entry := range entries {
name := entry.Name()
if name == ".repos" || strings.HasSuffix(name, ".meta") {
continue
}
fullPath := filepath.Join(m.pluginsDir, name)
isPlugin := false
if entry.IsDir() {
isPlugin = true
} else if entry.Mode()&os.ModeSymlink != 0 {
isPlugin = true
} else {
info, err := m.fs.Stat(fullPath)
if err == nil && info.IsDir() {
isPlugin = true
}
}
if isPlugin {
// Read plugin.json to get the actual plugin ID
pluginID := m.getPluginID(fullPath)
if pluginID != "" {
installedMap[pluginID] = true
}
}
}
}
systemPluginsDir := "/etc/xdg/quickshell/dms-plugins"
systemExists, err := afero.DirExists(m.fs, systemPluginsDir)
if err == nil && systemExists {
entries, err := afero.ReadDir(m.fs, systemPluginsDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
fullPath := filepath.Join(systemPluginsDir, entry.Name())
// Read plugin.json to get the actual plugin ID
pluginID := m.getPluginID(fullPath)
if pluginID != "" {
installedMap[pluginID] = true
}
}
}
}
}
var installed []string
for name := range installedMap {
installed = append(installed, name)
}
return installed, nil
}
// getPluginID reads the plugin.json file and returns the plugin ID
func (m *Manager) getPluginID(pluginPath string) string {
manifestPath := filepath.Join(pluginPath, "plugin.json")
data, err := afero.ReadFile(m.fs, manifestPath)
if err != nil {
return ""
}
var manifest struct {
ID string `json:"id"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
return ""
}
return manifest.ID
}
func (m *Manager) GetPluginsDir() string {
return m.pluginsDir
}
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
pluginPath := filepath.Join(m.pluginsDir, pluginID)
exists, err := afero.DirExists(m.fs, pluginPath)
if err != nil {
return false, fmt.Errorf("failed to check if plugin exists: %w", err)
}
if !exists {
systemPluginPath := filepath.Join("/etc/xdg/quickshell/dms-plugins", pluginID)
systemExists, err := afero.DirExists(m.fs, systemPluginPath)
if err != nil {
return false, fmt.Errorf("failed to check system plugin: %w", err)
}
if systemExists {
return false, nil
}
return false, fmt.Errorf("plugin not installed: %s", pluginID)
}
// Check if there's a .meta file (plugin installed from a monorepo)
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return false, fmt.Errorf("failed to check metadata: %w", err)
}
if metaExists {
// Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
return m.gitClient.HasUpdates(repoPath)
}
// Plugin is a standalone repo
return m.gitClient.HasUpdates(pluginPath)
}

View File

@@ -0,0 +1,247 @@
package plugins
import (
"os"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupTestManager(t *testing.T) (*Manager, afero.Fs, string) {
fs := afero.NewMemMapFs()
pluginsDir := "/test-plugins"
manager := &Manager{
fs: fs,
pluginsDir: pluginsDir,
gitClient: &mockGitClient{},
}
return manager, fs, pluginsDir
}
func TestNewManager(t *testing.T) {
manager, err := NewManager()
assert.NoError(t, err)
assert.NotNil(t, manager)
assert.NotEmpty(t, manager.pluginsDir)
}
func TestGetPluginsDir(t *testing.T) {
t.Run("uses XDG_CONFIG_HOME when set", func(t *testing.T) {
oldConfig := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if oldConfig != "" {
os.Setenv("XDG_CONFIG_HOME", oldConfig)
} else {
os.Unsetenv("XDG_CONFIG_HOME")
}
}()
os.Setenv("XDG_CONFIG_HOME", "/tmp/test-config")
dir := getPluginsDir()
assert.Equal(t, "/tmp/test-config/DankMaterialShell/plugins", dir)
})
t.Run("falls back to home directory", func(t *testing.T) {
oldConfig := os.Getenv("XDG_CONFIG_HOME")
defer func() {
if oldConfig != "" {
os.Setenv("XDG_CONFIG_HOME", oldConfig)
} else {
os.Unsetenv("XDG_CONFIG_HOME")
}
}()
os.Unsetenv("XDG_CONFIG_HOME")
dir := getPluginsDir()
assert.Contains(t, dir, ".config/DankMaterialShell/plugins")
})
}
func TestIsInstalled(t *testing.T) {
t.Run("returns true when plugin is installed", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
installed, err := manager.IsInstalled(plugin)
assert.NoError(t, err)
assert.True(t, installed)
})
t.Run("returns false when plugin is not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
installed, err := manager.IsInstalled(plugin)
assert.NoError(t, err)
assert.False(t, installed)
})
}
func TestInstall(t *testing.T) {
t.Run("installs plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{
ID: "test-plugin",
Name: "TestPlugin",
Repo: "https://github.com/test/plugin",
}
cloneCalled := false
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, filepath.Join(pluginsDir, plugin.ID), path)
assert.Equal(t, plugin.Repo, url)
return fs.MkdirAll(path, 0755)
},
}
manager.gitClient = mockGit
err := manager.Install(plugin)
assert.NoError(t, err)
assert.True(t, cloneCalled)
exists, _ := afero.DirExists(fs, filepath.Join(pluginsDir, plugin.ID))
assert.True(t, exists)
})
t.Run("returns error when plugin already installed", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
err = manager.Install(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already installed")
})
t.Run("installs monorepo plugin with symlink", func(t *testing.T) {
t.Skip("Skipping symlink test as MemMapFs doesn't support symlinks")
})
}
func TestManagerUpdate(t *testing.T) {
t.Run("updates plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
pullCalled := false
mockGit := &mockGitClient{
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, pluginPath, path)
return nil
},
}
manager.gitClient = mockGit
err = manager.Update(plugin)
assert.NoError(t, err)
assert.True(t, pullCalled)
})
t.Run("returns error when plugin not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
err := manager.Update(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not installed")
})
}
func TestUninstall(t *testing.T) {
t.Run("uninstalls plugin successfully", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
plugin := Plugin{ID: "test-plugin", Name: "TestPlugin"}
pluginPath := filepath.Join(pluginsDir, plugin.ID)
err := fs.MkdirAll(pluginPath, 0755)
require.NoError(t, err)
err = manager.Uninstall(plugin)
assert.NoError(t, err)
exists, _ := afero.DirExists(fs, pluginPath)
assert.False(t, exists)
})
t.Run("returns error when plugin not installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
plugin := Plugin{ID: "non-existent", Name: "NonExistent"}
err := manager.Uninstall(plugin)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not installed")
})
}
func TestListInstalled(t *testing.T) {
t.Run("lists installed plugins", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0644)
require.NoError(t, err)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Len(t, installed, 2)
assert.Contains(t, installed, "Plugin1")
assert.Contains(t, installed, "Plugin2")
})
t.Run("returns empty list when no plugins installed", func(t *testing.T) {
manager, _, _ := setupTestManager(t)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Empty(t, installed)
})
t.Run("ignores files and .repos directory", func(t *testing.T) {
manager, fs, pluginsDir := setupTestManager(t)
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0644)
require.NoError(t, err)
err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0644)
require.NoError(t, err)
installed, err := manager.ListInstalled()
assert.NoError(t, err)
assert.Len(t, installed, 1)
assert.Equal(t, "Plugin1", installed[0])
})
}
func TestManagerGetPluginsDir(t *testing.T) {
manager, _, pluginsDir := setupTestManager(t)
assert.Equal(t, pluginsDir, manager.GetPluginsDir())
}

View File

@@ -0,0 +1,256 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/go-git/go-git/v6"
"github.com/spf13/afero"
)
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
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"`
}
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
HasUpdates(path string) (bool, 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) HasUpdates(path string) (bool, 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
}
}
}
// If we couldn't find a remote HEAD, assume no updates
if remoteHead == "" {
return false, nil
}
// Compare local HEAD with remote HEAD
return head.Hash().String() != remoteHead, nil
}
type Registry struct {
fs afero.Fs
cacheDir string
plugins []Plugin
git GitClient
}
func NewRegistry() (*Registry, error) {
return NewRegistryWithFs(afero.NewOsFs())
}
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{
fs: fs,
cacheDir: cacheDir,
git: &realGitClient{},
}, nil
}
func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
}
func (r *Registry) Update() error {
exists, err := afero.DirExists(r.fs, r.cacheDir)
if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err)
}
if !exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
}
}
return r.loadPlugins()
}
func (r *Registry) loadPlugins() error {
pluginsDir := filepath.Join(r.cacheDir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil {
return fmt.Errorf("failed to read plugins directory: %w", err)
}
r.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")
}
r.plugins = append(r.plugins, plugin)
}
return nil
}
func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 {
if err := r.Update(); err != nil {
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)
}

View File

@@ -0,0 +1,326 @@
package plugins
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockGitClient struct {
cloneFunc func(path string, url string) error
pullFunc func(path string) error
hasUpdatesFunc func(path string) (bool, error)
}
func (m *mockGitClient) PlainClone(path string, url string) error {
if m.cloneFunc != nil {
return m.cloneFunc(path, url)
}
return nil
}
func (m *mockGitClient) Pull(path string) error {
if m.pullFunc != nil {
return m.pullFunc(path)
}
return nil
}
func (m *mockGitClient) HasUpdates(path string) (bool, error) {
if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path)
}
return false, nil
}
func TestNewRegistry(t *testing.T) {
registry, err := NewRegistry()
assert.NoError(t, err)
assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir)
}
func TestGetCacheDir(t *testing.T) {
cacheDir := getCacheDir()
assert.Contains(t, cacheDir, "/tmp/dankdots-plugin-registry")
}
func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
fs := afero.NewMemMapFs()
tmpDir := "/test-cache"
registry := &Registry{
fs: fs,
cacheDir: tmpDir,
plugins: []Plugin{},
git: &mockGitClient{},
}
return registry, fs, tmpDir
}
func createTestPlugin(t *testing.T, fs afero.Fs, dir string, filename string, plugin Plugin) {
pluginsDir := filepath.Join(dir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
data, err := json.Marshal(plugin)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0644)
require.NoError(t, err)
}
func TestLoadPlugins(t *testing.T) {
t.Run("loads valid plugin files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin1 := Plugin{
Name: "TestPlugin1",
Capabilities: []string{"dankbar-widget"},
Category: "monitoring",
Repo: "https://github.com/test/plugin1",
Author: "Test Author",
Description: "Test plugin 1",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
plugin2 := Plugin{
Name: "TestPlugin2",
Capabilities: []string{"system-tray"},
Category: "utilities",
Repo: "https://github.com/test/plugin2",
Author: "Another Author",
Description: "Test plugin 2",
Dependencies: []string{"dep1", "dep2"},
Compositors: []string{"hyprland", "niri"},
Distro: []string{"arch"},
Screenshot: "https://example.com/screenshot.png",
}
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
err := registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 2)
assert.Equal(t, "TestPlugin1", registry.plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies)
})
t.Run("skips non-json files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0644)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
})
t.Run("skips invalid json files", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(pluginsDir, 0755)
require.NoError(t, err)
err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0644)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
})
t.Run("returns error when plugins directory missing", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
err := registry.loadPlugins()
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory")
})
}
func TestList(t *testing.T) {
t.Run("returns cached plugins if available", func(t *testing.T) {
registry, _, _ := setupTestRegistry(t)
plugin := Plugin{
Name: "CachedPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
registry.plugins = []Plugin{plugin}
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "CachedPlugin", plugins[0].Name)
})
t.Run("updates and loads plugins when cache is empty", func(t *testing.T) {
registry, fs, _ := setupTestRegistry(t)
plugin := Plugin{
Name: "NewPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
plugins, err := registry.List()
assert.NoError(t, err)
assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name)
})
}
func TestUpdate(t *testing.T) {
t.Run("clones repository when cache doesn't exist", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
Name: "RepoPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
cloneCalled := false
mockGit := &mockGitClient{
cloneFunc: func(path string, url string) error {
cloneCalled = true
assert.Equal(t, registryRepo, url)
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err := registry.Update()
assert.NoError(t, err)
assert.True(t, cloneCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
})
t.Run("pulls updates when cache exists", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{
Name: "UpdatedPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
err := fs.MkdirAll(tmpDir, 0755)
require.NoError(t, err)
pullCalled := false
mockGit := &mockGitClient{
pullFunc: func(path string) error {
pullCalled = true
assert.Equal(t, tmpDir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil
},
}
registry.git = mockGit
err = registry.Update()
assert.NoError(t, err)
assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name)
})
}

View File

@@ -0,0 +1,105 @@
package plugins
import (
"sort"
"strings"
)
func FuzzySearch(query string, plugins []Plugin) []Plugin {
if query == "" {
return plugins
}
queryLower := strings.ToLower(query)
var results []Plugin
for _, plugin := range plugins {
if fuzzyMatch(queryLower, strings.ToLower(plugin.Name)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Category)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Description)) ||
fuzzyMatch(queryLower, strings.ToLower(plugin.Author)) {
results = append(results, plugin)
}
}
return results
}
func fuzzyMatch(query, text string) bool {
queryIdx := 0
for _, char := range text {
if queryIdx < len(query) && char == rune(query[queryIdx]) {
queryIdx++
}
}
return queryIdx == len(query)
}
func FilterByCategory(category string, plugins []Plugin) []Plugin {
if category == "" {
return plugins
}
var results []Plugin
categoryLower := strings.ToLower(category)
for _, plugin := range plugins {
if strings.ToLower(plugin.Category) == categoryLower {
results = append(results, plugin)
}
}
return results
}
func FilterByCompositor(compositor string, plugins []Plugin) []Plugin {
if compositor == "" {
return plugins
}
var results []Plugin
compositorLower := strings.ToLower(compositor)
for _, plugin := range plugins {
for _, comp := range plugin.Compositors {
if strings.ToLower(comp) == compositorLower {
results = append(results, plugin)
break
}
}
}
return results
}
func FilterByCapability(capability string, plugins []Plugin) []Plugin {
if capability == "" {
return plugins
}
var results []Plugin
capabilityLower := strings.ToLower(capability)
for _, plugin := range plugins {
for _, cap := range plugin.Capabilities {
if strings.ToLower(cap) == capabilityLower {
results = append(results, plugin)
break
}
}
}
return results
}
func SortByFirstParty(plugins []Plugin) []Plugin {
sort.SliceStable(plugins, func(i, j int) bool {
isFirstPartyI := strings.HasPrefix(plugins[i].Repo, "https://github.com/AvengeMedia")
isFirstPartyJ := strings.HasPrefix(plugins[j].Repo, "https://github.com/AvengeMedia")
if isFirstPartyI != isFirstPartyJ {
return isFirstPartyI
}
return false
})
return plugins
}