summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager.go588
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/manager_test.go247
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry.go258
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/registry_test.go326
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/plugins/search.go91
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
+}