package plugins import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "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 { return filepath.Join(utils.XDGConfigHome(), "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, 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) //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), 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) //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) }