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:
430
backend/internal/plugins/manager.go
Normal file
430
backend/internal/plugins/manager.go
Normal 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)
|
||||
}
|
||||
247
backend/internal/plugins/manager_test.go
Normal file
247
backend/internal/plugins/manager_test.go
Normal 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())
|
||||
}
|
||||
256
backend/internal/plugins/registry.go
Normal file
256
backend/internal/plugins/registry.go
Normal 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)
|
||||
}
|
||||
326
backend/internal/plugins/registry_test.go
Normal file
326
backend/internal/plugins/registry_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
105
backend/internal/plugins/search.go
Normal file
105
backend/internal/plugins/search.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user