diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins')
5 files changed, 1510 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager.go new file mode 100644 index 0000000..1c0ddf5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager.go @@ -0,0 +1,588 @@ +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "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 { + configDir, err := os.UserConfigDir() + if err != nil { + log.Error("failed to get user config dir", "err", err) + return "" + } + return filepath.Join(configDir, "DankMaterialShell", "plugins") +} + +func (m *Manager) IsInstalled(plugin Plugin) (bool, error) { + path, err := m.findInstalledPath(plugin.ID) + if err != nil { + return false, err + } + return path != "", nil +} + +func (m *Manager) findInstalledPath(pluginID string) (string, error) { + // Check user plugins directory + path, err := m.findInDir(m.pluginsDir, pluginID) + if err != nil { + return "", err + } + if path != "" { + return path, nil + } + + // Check system plugins directory + systemDir := "/etc/xdg/quickshell/dms-plugins" + return m.findInDir(systemDir, pluginID) +} + +func (m *Manager) findInDir(dir, pluginID string) (string, error) { + // First, check if folder with exact ID name exists + exactPath := filepath.Join(dir, pluginID) + if exists, _ := afero.DirExists(m.fs, exactPath); exists { + return exactPath, nil + } + + // Scan all folders and check plugin.json for matching ID + exists, err := afero.DirExists(m.fs, dir) + if err != nil || !exists { + return "", nil + } + + entries, err := afero.ReadDir(m.fs, dir) + if err != nil { + return "", nil + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + fullPath := filepath.Join(dir, name) + isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0 + if !isPlugin { + if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() { + isPlugin = true + } + } + + if isPlugin && m.getPluginID(fullPath) == pluginID { + return fullPath, nil + } + } + + return "", 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, 0o755); err != nil { + return fmt.Errorf("failed to create plugins directory: %w", err) + } + + reposDir := filepath.Join(m.pluginsDir, ".repos") + if err := m.fs.MkdirAll(reposDir, 0o755); 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) //nolint:errcheck + 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), 0o644); 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) //nolint:errcheck + 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, err := m.findInstalledPath(plugin.ID) + if err != nil { + return fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot update system plugin: %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, err := m.findInstalledPath(plugin.ID) + if err != nil { + return fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return fmt.Errorf("plugin not installed: %s", plugin.Name) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot uninstall system plugin: %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 { + manifest := m.getPluginManifest(pluginPath) + if manifest == nil { + return "" + } + return manifest.ID +} + +func (m *Manager) getPluginManifest(pluginPath string) *pluginManifest { + manifestPath := filepath.Join(pluginPath, "plugin.json") + data, err := afero.ReadFile(m.fs, manifestPath) + if err != nil { + return nil + } + + var manifest pluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil + } + + return &manifest +} + +type pluginManifest struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (m *Manager) GetPluginsDir() string { + return m.pluginsDir +} + +func (m *Manager) UninstallByIDOrName(idOrName string) error { + pluginPath, err := m.findInstalledPathByIDOrName(idOrName) + if err != nil { + return err + } + if pluginPath == "" { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot uninstall system plugin: %s", idOrName) + } + + metaPath := pluginPath + ".meta" + metaExists, _ := afero.Exists(m.fs, metaPath) + + if metaExists { + 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) + } + } else { + if err := m.fs.RemoveAll(pluginPath); err != nil { + return fmt.Errorf("failed to remove plugin: %w", err) + } + } + + return nil +} + +func (m *Manager) UpdateByIDOrName(idOrName string) error { + pluginPath, err := m.findInstalledPathByIDOrName(idOrName) + if err != nil { + return err + } + if pluginPath == "" { + return fmt.Errorf("plugin not found: %s", idOrName) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return fmt.Errorf("cannot update system plugin: %s", idOrName) + } + + metaPath := pluginPath + ".meta" + metaExists, _ := afero.Exists(m.fs, metaPath) + + if metaExists { + // Plugin is from monorepo, but we don't know the repo URL without registry + // Just try to pull from existing .git in the symlink target + return fmt.Errorf("cannot update monorepo plugin without registry info: %s", idOrName) + } + + // Standalone plugin - just pull + if err := m.gitClient.Pull(pluginPath); err != nil { + return fmt.Errorf("failed to update plugin: %w", err) + } + + return nil +} + +func (m *Manager) findInstalledPathByIDOrName(idOrName string) (string, error) { + path, err := m.findInDirByIDOrName(m.pluginsDir, idOrName) + if err != nil { + return "", err + } + if path != "" { + return path, nil + } + + systemDir := "/etc/xdg/quickshell/dms-plugins" + return m.findInDirByIDOrName(systemDir, idOrName) +} + +func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) { + // Check exact folder name match first + exactPath := filepath.Join(dir, idOrName) + if exists, _ := afero.DirExists(m.fs, exactPath); exists { + return exactPath, nil + } + + exists, err := afero.DirExists(m.fs, dir) + if err != nil || !exists { + return "", nil + } + + entries, err := afero.ReadDir(m.fs, dir) + if err != nil { + return "", nil + } + + for _, entry := range entries { + name := entry.Name() + if name == ".repos" || strings.HasSuffix(name, ".meta") { + continue + } + + fullPath := filepath.Join(dir, name) + isPlugin := entry.IsDir() || entry.Mode()&os.ModeSymlink != 0 + if !isPlugin { + if info, err := m.fs.Stat(fullPath); err == nil && info.IsDir() { + isPlugin = true + } + } + + if !isPlugin { + continue + } + + manifest := m.getPluginManifest(fullPath) + if manifest == nil { + continue + } + + if manifest.ID == idOrName || manifest.Name == idOrName { + return fullPath, nil + } + } + + return "", nil +} + +func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) { + pluginPath, err := m.findInstalledPath(pluginID) + if err != nil { + return false, fmt.Errorf("failed to find plugin: %w", err) + } + + if pluginPath == "" { + return false, fmt.Errorf("plugin not installed: %s", pluginID) + } + + if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") { + return false, nil + } + + 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) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go new file mode 100644 index 0000000..485479e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go @@ -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, 0o755) + 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, 0o755) + }, + } + 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, 0o755) + 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, 0o755) + 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, 0o755) + 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"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) + require.NoError(t, err) + + err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin2"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin2", "plugin.json"), []byte(`{"id":"Plugin2"}`), 0o644) + 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, 0o755) + require.NoError(t, err) + err = fs.MkdirAll(filepath.Join(pluginsDir, "Plugin1"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "Plugin1", "plugin.json"), []byte(`{"id":"Plugin1"}`), 0o644) + require.NoError(t, err) + err = fs.MkdirAll(filepath.Join(pluginsDir, ".repos"), 0o755) + require.NoError(t, err) + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("test"), 0o644) + 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()) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry.go new file mode 100644 index 0000000..1728e72 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry.go @@ -0,0 +1,258 @@ +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"` + RequiresDMS string `json:"requires_dms,omitempty"` + Featured bool `json:"featured,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), 0o755); 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), 0o755); 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) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go new file mode 100644 index 0000000..e535e6f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go @@ -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, 0o755) + require.NoError(t, err) + + data, err := json.Marshal(plugin) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, filename), data, 0o644) + 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, 0o755) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "README.md"), []byte("# Test"), 0o644) + 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"), 0o755) + 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, 0o755) + require.NoError(t, err) + + err = afero.WriteFile(fs, filepath.Join(pluginsDir, "invalid.json"), []byte("{invalid json}"), 0o644) + 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, 0o755) + 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) + }) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/search.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/search.go new file mode 100644 index 0000000..833158f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/search.go @@ -0,0 +1,91 @@ +package plugins + +import ( + "sort" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func FuzzySearch(query string, plugins []Plugin) []Plugin { + if query == "" { + return plugins + } + + queryLower := strings.ToLower(query) + return utils.Filter(plugins, func(p Plugin) bool { + return fuzzyMatch(queryLower, strings.ToLower(p.Name)) || + fuzzyMatch(queryLower, strings.ToLower(p.Category)) || + fuzzyMatch(queryLower, strings.ToLower(p.Description)) || + fuzzyMatch(queryLower, strings.ToLower(p.Author)) + }) +} + +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 + } + categoryLower := strings.ToLower(category) + return utils.Filter(plugins, func(p Plugin) bool { + return strings.ToLower(p.Category) == categoryLower + }) +} + +func FilterByCompositor(compositor string, plugins []Plugin) []Plugin { + if compositor == "" { + return plugins + } + compositorLower := strings.ToLower(compositor) + return utils.Filter(plugins, func(p Plugin) bool { + return utils.Any(p.Compositors, func(c string) bool { + return strings.ToLower(c) == compositorLower + }) + }) +} + +func FilterByCapability(capability string, plugins []Plugin) []Plugin { + if capability == "" { + return plugins + } + capabilityLower := strings.ToLower(capability) + return utils.Filter(plugins, func(p Plugin) bool { + return utils.Any(p.Capabilities, func(c string) bool { + return strings.ToLower(c) == capabilityLower + }) + }) +} + +func SortByFirstParty(plugins []Plugin) []Plugin { + sort.SliceStable(plugins, func(i, j int) bool { + if plugins[i].Featured != plugins[j].Featured { + return plugins[i].Featured + } + 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 +} + +func FindByIDOrName(idOrName string, plugins []Plugin) *Plugin { + if p, found := utils.Find(plugins, func(p Plugin) bool { return p.ID == idOrName }); found { + return &p + } + if p, found := utils.Find(plugins, func(p Plugin) bool { return p.Name == idOrName }); found { + return &p + } + return nil +} |