mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-07 05:58:28 -04:00
feat(registry): support multiple plugin and theme registries via env vars (#2972)
feat(registry): support multiple plugin/theme registries closes #2763 --------- Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
@@ -704,6 +704,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
ipcCmd,
|
||||
debugSrvCmd,
|
||||
pluginsCmd,
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
dpmsCmd,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var registryCmd = &cobra.Command{
|
||||
Use: "registry",
|
||||
Short: "Manage plugin and theme registries",
|
||||
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
|
||||
}
|
||||
|
||||
var registryListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured registries",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
suffix := ""
|
||||
if s.Official() {
|
||||
suffix = " (official)"
|
||||
}
|
||||
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var registryAddCmd = &cobra.Command{
|
||||
Use: "add <name> <url>",
|
||||
Short: "Add a registry",
|
||||
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
|
||||
log.Fatalf("Error adding registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry added: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var registryRemoveCmd = &cobra.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove a registry",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
var names []string
|
||||
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||
if !s.Official() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
|
||||
log.Fatalf("Error removing registry: %v", err)
|
||||
}
|
||||
fmt.Printf("Registry removed: %s\n", args[0])
|
||||
},
|
||||
}
|
||||
@@ -16,6 +16,7 @@ func init() {
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
@@ -2,17 +2,17 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"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"`
|
||||
@@ -33,6 +33,7 @@ type Plugin struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
|
||||
}
|
||||
|
||||
@@ -65,6 +66,22 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) OriginURL(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return "", errors.New("origin remote has no URL")
|
||||
}
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
@@ -119,10 +136,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
plugins []Plugin
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -130,63 +148,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
git: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
||||
return filepath.Join(r.cacheDir, src.Name)
|
||||
}
|
||||
|
||||
func getCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
// A cached clone is reused only when its origin still matches the configured
|
||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
||||
// stale remote.
|
||||
func (r *Registry) updateOne(src registries.Source) error {
|
||||
dir := r.cacheDirFor(src)
|
||||
exists, err := afero.DirExists(r.fs, dir)
|
||||
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 exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if err := r.fs.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.loadPlugins()
|
||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
||||
return fmt.Errorf("failed to clone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) loadPlugins() error {
|
||||
pluginsDir := filepath.Join(r.cacheDir, "plugins")
|
||||
|
||||
// A registry without a plugins/ directory is a valid themes-only registry.
|
||||
func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
|
||||
pluginsDir := filepath.Join(dir, "plugins")
|
||||
entries, err := afero.ReadDir(r.fs, pluginsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read plugins directory: %w", err)
|
||||
}
|
||||
|
||||
r.plugins = []Plugin{}
|
||||
|
||||
var plugins []Plugin
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
@@ -206,15 +224,51 @@ func (r *Registry) loadPlugins() error {
|
||||
plugin.ID = strings.TrimSuffix(entry.Name(), ".json")
|
||||
}
|
||||
|
||||
r.plugins = append(r.plugins, plugin)
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
||||
func (r *Registry) resetLegacyCache() {
|
||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
||||
_ = r.fs.RemoveAll(r.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes every configured registry, aggregating plugins in
|
||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
||||
// reported in the joined error but does not block the others.
|
||||
func (r *Registry) Update() error {
|
||||
r.resetLegacyCache()
|
||||
r.plugins = []Plugin{}
|
||||
seen := make(map[string]struct{})
|
||||
var errs []error
|
||||
for _, src := range r.registries {
|
||||
if err := r.updateOne(src); err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
plugins, err := r.loadPluginsFrom(r.cacheDirFor(src))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, p := range plugins {
|
||||
if _, dup := seen[p.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p.ID] = struct{}{}
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (r *Registry) List() ([]Plugin, error) {
|
||||
if len(r.plugins) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
if err := r.Update(); err != nil && len(r.plugins) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testRegistryURL = "https://example.com/test-registry.git"
|
||||
|
||||
type mockGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
pullFunc func(path string) error
|
||||
originFunc func(path string) (string, error)
|
||||
hasUpdatesFunc func(path string) (bool, string, string, error)
|
||||
}
|
||||
|
||||
@@ -30,6 +35,13 @@ func (m *mockGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGitClient) OriginURL(path string) (string, error) {
|
||||
if m.originFunc != nil {
|
||||
return m.originFunc(path)
|
||||
}
|
||||
return "", errors.New("not a repository")
|
||||
}
|
||||
|
||||
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
|
||||
if m.hasUpdatesFunc != nil {
|
||||
return m.hasUpdatesFunc(path)
|
||||
@@ -42,6 +54,8 @@ func TestNewRegistry(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, registry)
|
||||
assert.NotEmpty(t, registry.cacheDir)
|
||||
require.NotEmpty(t, registry.registries)
|
||||
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
|
||||
}
|
||||
|
||||
func TestGetCacheDir(t *testing.T) {
|
||||
@@ -53,10 +67,11 @@ 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{},
|
||||
fs: fs,
|
||||
cacheDir: tmpDir,
|
||||
registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
|
||||
plugins: []Plugin{},
|
||||
git: &mockGitClient{},
|
||||
}
|
||||
return registry, fs, tmpDir
|
||||
}
|
||||
@@ -104,14 +119,14 @@ func TestLoadPlugins(t *testing.T) {
|
||||
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
|
||||
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
|
||||
|
||||
err := registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
assert.Len(t, 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)
|
||||
assert.Equal(t, "TestPlugin1", plugins[0].Name)
|
||||
assert.Equal(t, "TestPlugin2", plugins[1].Name)
|
||||
assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
|
||||
assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
|
||||
})
|
||||
|
||||
t.Run("skips non-json files", func(t *testing.T) {
|
||||
@@ -136,34 +151,10 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
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)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("skips invalid json files", func(t *testing.T) {
|
||||
@@ -188,18 +179,18 @@ func TestLoadPlugins(t *testing.T) {
|
||||
}
|
||||
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
|
||||
|
||||
err = registry.loadPlugins()
|
||||
plugins, err := registry.loadPluginsFrom(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name)
|
||||
assert.Len(t, plugins, 1)
|
||||
assert.Equal(t, "ValidPlugin", plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("returns error when plugins directory missing", func(t *testing.T) {
|
||||
t.Run("missing plugins directory is a themes-only registry", func(t *testing.T) {
|
||||
registry, _, _ := setupTestRegistry(t)
|
||||
|
||||
err := registry.loadPlugins()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read plugins directory")
|
||||
plugins, err := registry.loadPluginsFrom(registry.cacheDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, plugins)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,19 +231,40 @@ func TestList(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &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)
|
||||
})
|
||||
|
||||
t.Run("partial registry failure still returns loaded plugins", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("clone failed")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
plugins, err := registry.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, plugins, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
@@ -271,16 +283,15 @@ func TestUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
cloneCalled := false
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, registryRepo, url)
|
||||
assert.Equal(t, tmpDir, path)
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
assert.Equal(t, filepath.Join(tmpDir, "test"), path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
@@ -289,7 +300,7 @@ func TestUpdate(t *testing.T) {
|
||||
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("pulls updates when cache exists", func(t *testing.T) {
|
||||
t.Run("pulls when cache exists with matching origin", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
plugin := Plugin{
|
||||
@@ -303,24 +314,188 @@ func TestUpdate(t *testing.T) {
|
||||
Distro: []string{"any"},
|
||||
}
|
||||
|
||||
err := fs.MkdirAll(tmpDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
|
||||
pullCalled := false
|
||||
mockGit := &mockGitClient{
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
assert.Equal(t, tmpDir, path)
|
||||
assert.Equal(t, subdir, path)
|
||||
createTestPlugin(t, fs, path, "plugin.json", plugin)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
registry.git = mockGit
|
||||
|
||||
err = registry.Update()
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("re-clones when cached origin does not match configured URL", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "stale"), []byte("x"), 0o644))
|
||||
|
||||
pullCalled := false
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return "https://example.com/old-origin.git", nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
pullCalled = true
|
||||
return nil
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
assert.Equal(t, testRegistryURL, url)
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pullCalled, "stale origin must not be pulled")
|
||||
assert.True(t, cloneCalled)
|
||||
exists, _ := afero.Exists(fs, filepath.Join(subdir, "stale"))
|
||||
assert.False(t, exists, "stale cache contents removed before re-clone")
|
||||
})
|
||||
|
||||
t.Run("re-clones when pull fails", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
subdir := filepath.Join(tmpDir, "test")
|
||||
require.NoError(t, fs.MkdirAll(subdir, 0o755))
|
||||
|
||||
cloneCalled := false
|
||||
registry.git = &mockGitClient{
|
||||
originFunc: func(path string) (string, error) {
|
||||
return testRegistryURL, nil
|
||||
},
|
||||
pullFunc: func(path string) error {
|
||||
return errors.New("shallow clone corruption")
|
||||
},
|
||||
cloneFunc: func(path string, url string) error {
|
||||
cloneCalled = true
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cloneCalled)
|
||||
})
|
||||
|
||||
t.Run("aggregates from multiple registries", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginA := Plugin{ID: "a", Name: "PluginA", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginB := Plugin{ID: "b", Name: "PluginB", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginA)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginB)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 2)
|
||||
assert.Equal(t, "a", registry.plugins[0].ID)
|
||||
assert.Equal(t, "b", registry.plugins[1].ID)
|
||||
})
|
||||
|
||||
t.Run("dedupes by ID with declaration order priority", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
pluginOfficial := Plugin{ID: "weather", Name: "OfficialWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
pluginLouzt := Plugin{ID: "weather", Name: "LouztWeather", Compositors: []string{"niri"}, Distro: []string{"any"}}
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "official", URL: testRegistryURL},
|
||||
{Name: "louzt", URL: "https://example.com/louzt.git"},
|
||||
}
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch filepath.Base(path) {
|
||||
case "official":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginOfficial)
|
||||
case "louzt":
|
||||
createTestPlugin(t, fs, path, "x.json", pluginLouzt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "OfficialWeather", registry.plugins[0].Name)
|
||||
})
|
||||
|
||||
t.Run("continues past failing registry and reports it", func(t *testing.T) {
|
||||
registry, fs, _ := setupTestRegistry(t)
|
||||
|
||||
registry.registries = []registries.Source{
|
||||
{Name: "broken", URL: "https://example.com/broken.git"},
|
||||
{Name: "test", URL: testRegistryURL},
|
||||
}
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
if url != testRegistryURL {
|
||||
return errors.New("network unreachable")
|
||||
}
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "registry broken")
|
||||
assert.Len(t, registry.plugins, 1, "healthy registry still loads")
|
||||
})
|
||||
|
||||
t.Run("removes legacy single-clone cache at base", func(t *testing.T) {
|
||||
registry, fs, tmpDir := setupTestRegistry(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
|
||||
createTestPlugin(t, fs, tmpDir, "legacy.json", Plugin{ID: "legacy", Name: "Legacy"})
|
||||
|
||||
registry.git = &mockGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
createTestPlugin(t, fs, path, "x.json", Plugin{ID: "x", Name: "X"})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := registry.Update()
|
||||
assert.NoError(t, err)
|
||||
exists, _ := afero.DirExists(fs, filepath.Join(tmpDir, ".git"))
|
||||
assert.False(t, exists, "legacy clone removed")
|
||||
assert.Len(t, registry.plugins, 1)
|
||||
assert.Equal(t, "x", registry.plugins[0].ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
OfficialName = "official"
|
||||
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
)
|
||||
|
||||
// Source identifies a registry repository. Name doubles as the per-registry
|
||||
// cache subdirectory, so it is restricted to a filesystem-safe slug.
|
||||
type Source struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s Source) Official() bool {
|
||||
return s.Name == OfficialName
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
func configPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
|
||||
}
|
||||
|
||||
// Load returns the official registry followed by any user-configured extras.
|
||||
// A missing or unreadable config yields just the official registry.
|
||||
func Load(fs afero.Fs) []Source {
|
||||
sources := []Source{{Name: OfficialName, URL: officialURL}}
|
||||
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
data, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return sources
|
||||
}
|
||||
var extras []Source
|
||||
if err := json.Unmarshal(data, &extras); err != nil {
|
||||
return sources
|
||||
}
|
||||
for _, s := range extras {
|
||||
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, s)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func saveExtras(fs afero.Fs, extras []Source) error {
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(extras, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func loadExtras(fs afero.Fs) []Source {
|
||||
sources := Load(fs)
|
||||
return sources[1:]
|
||||
}
|
||||
|
||||
func Add(fs afero.Fs, name, url string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
url = strings.TrimSpace(url)
|
||||
|
||||
if !nameRe.MatchString(name) {
|
||||
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
|
||||
}
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("registry name %q is reserved", OfficialName)
|
||||
}
|
||||
if url == "" {
|
||||
return fmt.Errorf("registry URL is required")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
for _, s := range extras {
|
||||
if s.Name == name {
|
||||
return fmt.Errorf("registry %q already exists", name)
|
||||
}
|
||||
if s.URL == url {
|
||||
return fmt.Errorf("registry %q already uses this URL", s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
|
||||
}
|
||||
|
||||
func Remove(fs afero.Fs, name string) error {
|
||||
if name == OfficialName {
|
||||
return fmt.Errorf("the official registry cannot be removed")
|
||||
}
|
||||
|
||||
extras := loadExtras(fs)
|
||||
kept := make([]Source, 0, len(extras))
|
||||
for _, s := range extras {
|
||||
if s.Name != name {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(extras) {
|
||||
return fmt.Errorf("registry %q not found", name)
|
||||
}
|
||||
|
||||
return saveExtras(fs, kept)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupFs(t *testing.T) afero.Fs {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/xdg")
|
||||
return afero.NewMemMapFs()
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 1)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL)
|
||||
assert.True(t, sources[0].Official())
|
||||
}
|
||||
|
||||
func TestAddAndLoad(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
|
||||
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
|
||||
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 3)
|
||||
assert.Equal(t, OfficialName, sources[0].Name)
|
||||
assert.Equal(t, "extra", sources[1].Name)
|
||||
assert.Equal(t, "another", sources[2].Name)
|
||||
assert.False(t, sources[1].Official())
|
||||
}
|
||||
|
||||
func TestAddValidation(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "noname", ""))
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
|
||||
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||
require.NoError(t, Remove(fs, "extra"))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
assert.Error(t, Remove(fs, "extra"), "already removed")
|
||||
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
|
||||
}
|
||||
|
||||
func TestLoadIgnoresInvalidConfig(t *testing.T) {
|
||||
fs := setupFs(t)
|
||||
|
||||
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
|
||||
assert.Len(t, Load(fs), 1)
|
||||
|
||||
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
|
||||
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
|
||||
sources := Load(fs)
|
||||
require.Len(t, sources, 2, "invalid entries dropped")
|
||||
assert.Equal(t, "ok", sources[1].Name)
|
||||
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RegistryInfo struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Official bool `json:"official"`
|
||||
}
|
||||
|
||||
type SuccessResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func HandleRequest(conn *models.Conn, req models.Request) {
|
||||
switch req.Method {
|
||||
case "registries.list":
|
||||
HandleList(conn, req)
|
||||
case "registries.add":
|
||||
HandleAdd(conn, req)
|
||||
case "registries.remove":
|
||||
HandleRemove(conn, req)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func HandleList(conn *models.Conn, req models.Request) {
|
||||
sources := registries.Load(afero.NewOsFs())
|
||||
result := make([]RegistryInfo, len(sources))
|
||||
for i, s := range sources {
|
||||
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
|
||||
}
|
||||
models.Respond(conn, req.ID, result)
|
||||
}
|
||||
|
||||
func HandleAdd(conn *models.Conn, req models.Request) {
|
||||
name, ok := models.Get[string](req, "name")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||
return
|
||||
}
|
||||
url, ok := models.Get[string](req, "url")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("registry added: %s", name),
|
||||
})
|
||||
}
|
||||
|
||||
func HandleRemove(conn *models.Conn, req models.Request) {
|
||||
name, ok := models.Get[string](req, "name")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, SuccessResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("registry removed: %s", name),
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||
serverRegistries "github.com/AvengeMedia/DankMaterialShell/core/internal/server/registries"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
@@ -47,6 +48,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "registries.") {
|
||||
serverRegistries.HandleRequest(conn, req)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "theme.auto.") {
|
||||
if themeModeManager == nil {
|
||||
models.RespondError(conn, req.ID, "theme mode manager not initialized")
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
"github.com/AvengeMedia/dankgo/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 28
|
||||
const APIVersion = 29
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||
|
||||
type ColorScheme struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
PrimaryText string `json:"primaryText,omitempty"`
|
||||
@@ -151,6 +150,7 @@ type Theme struct {
|
||||
type GitClient interface {
|
||||
PlainClone(path string, url string) error
|
||||
Pull(path string) error
|
||||
OriginURL(path string) (string, error)
|
||||
}
|
||||
|
||||
type realGitClient struct{}
|
||||
@@ -182,11 +182,28 @@ func (g *realGitClient) Pull(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *realGitClient) OriginURL(path string) (string, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
urls := remote.Config().URLs
|
||||
if len(urls) == 0 {
|
||||
return "", errors.New("origin remote has no URL")
|
||||
}
|
||||
return urls[0], nil
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
themes []Theme
|
||||
git GitClient
|
||||
fs afero.Fs
|
||||
cacheDir string
|
||||
registries []registries.Source
|
||||
themes []Theme
|
||||
git GitClient
|
||||
}
|
||||
|
||||
func NewRegistry() (*Registry, error) {
|
||||
@@ -194,61 +211,63 @@ func NewRegistry() (*Registry, error) {
|
||||
}
|
||||
|
||||
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
|
||||
cacheDir := getCacheDir()
|
||||
return &Registry{
|
||||
fs: fs,
|
||||
cacheDir: cacheDir,
|
||||
git: &realGitClient{},
|
||||
fs: fs,
|
||||
cacheDir: getCacheDir(),
|
||||
registries: registries.Load(fs),
|
||||
git: &realGitClient{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) cacheDirFor(src registries.Source) string {
|
||||
return filepath.Join(r.cacheDir, src.Name)
|
||||
}
|
||||
|
||||
func getCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
|
||||
}
|
||||
|
||||
func (r *Registry) Update() error {
|
||||
exists, err := afero.DirExists(r.fs, r.cacheDir)
|
||||
// A cached clone is reused only when its origin still matches the configured
|
||||
// URL; renamed or re-pointed registries re-clone instead of pulling from the
|
||||
// stale remote.
|
||||
func (r *Registry) updateOne(src registries.Source) error {
|
||||
dir := r.cacheDirFor(src)
|
||||
exists, err := afero.DirExists(r.fs, dir)
|
||||
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 exists {
|
||||
origin, originErr := r.git.OriginURL(dir)
|
||||
if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
|
||||
return fmt.Errorf("failed to clone registry: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := r.git.Pull(r.cacheDir); err != nil {
|
||||
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)
|
||||
}
|
||||
if err := r.fs.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("failed to remove stale registry cache: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return r.loadThemes()
|
||||
if err := r.fs.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
if err := r.git.PlainClone(dir, src.URL); err != nil {
|
||||
return fmt.Errorf("failed to clone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) loadThemes() error {
|
||||
themesDir := filepath.Join(r.cacheDir, "themes")
|
||||
|
||||
// A registry without a themes/ directory is a valid plugins-only registry.
|
||||
func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
|
||||
themesDir := filepath.Join(dir, "themes")
|
||||
entries, err := afero.ReadDir(r.fs, themesDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read themes directory: %w", err)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read themes directory: %w", err)
|
||||
}
|
||||
|
||||
r.themes = []Theme{}
|
||||
|
||||
var themes []Theme
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
@@ -278,10 +297,46 @@ func (r *Registry) loadThemes() error {
|
||||
theme.PreviewPath = previewPath
|
||||
}
|
||||
|
||||
r.themes = append(r.themes, theme)
|
||||
themes = append(themes, theme)
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// Pre-multi-registry caches were a single clone at the base dir; the per-name
|
||||
// layout nests under it, so a leftover clone is deleted wholesale first.
|
||||
func (r *Registry) resetLegacyCache() {
|
||||
if exists, _ := afero.DirExists(r.fs, filepath.Join(r.cacheDir, ".git")); exists {
|
||||
_ = r.fs.RemoveAll(r.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update refreshes every configured registry, aggregating themes in
|
||||
// declaration order (first occurrence of an ID wins). A failing registry is
|
||||
// reported in the joined error but does not block the others.
|
||||
func (r *Registry) Update() error {
|
||||
r.resetLegacyCache()
|
||||
r.themes = []Theme{}
|
||||
seen := make(map[string]struct{})
|
||||
var errs []error
|
||||
for _, src := range r.registries {
|
||||
if err := r.updateOne(src); err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
themes, err := r.loadThemesFrom(r.cacheDirFor(src))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("registry %s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, t := range themes {
|
||||
if _, dup := seen[t.ID]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t.ID] = struct{}{}
|
||||
r.themes = append(r.themes, t)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
||||
@@ -300,7 +355,7 @@ func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG {
|
||||
|
||||
func (r *Registry) List() ([]Theme, error) {
|
||||
if len(r.themes) == 0 {
|
||||
if err := r.Update(); err != nil {
|
||||
if err := r.Update(); err != nil && len(r.themes) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -343,11 +398,25 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeSourcePath(themeID string) string {
|
||||
return filepath.Join(r.cacheDir, "themes", themeID, "theme.json")
|
||||
// Themes may live under any registry's subdir. Search them all; first hit wins.
|
||||
for _, cfg := range r.registries {
|
||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID, "theme.json")
|
||||
if exists, _ := afero.Exists(r.fs, candidate); exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Fallback to first registry (legacy path semantics).
|
||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID, "theme.json")
|
||||
}
|
||||
|
||||
func (r *Registry) GetThemeDir(themeID string) string {
|
||||
return filepath.Join(r.cacheDir, "themes", themeID)
|
||||
for _, cfg := range r.registries {
|
||||
candidate := filepath.Join(r.cacheDirFor(cfg), "themes", themeID)
|
||||
if exists, _ := afero.DirExists(r.fs, candidate); exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return filepath.Join(r.cacheDirFor(r.registries[0]), "themes", themeID)
|
||||
}
|
||||
|
||||
func SortByFirstParty(themes []Theme) []Theme {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package themes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
@@ -64,3 +66,73 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
|
||||
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag)
|
||||
}
|
||||
}
|
||||
|
||||
type stubGitClient struct {
|
||||
cloneFunc func(path string, url string) error
|
||||
}
|
||||
|
||||
func (s *stubGitClient) PlainClone(path string, url string) error {
|
||||
if s.cloneFunc != nil {
|
||||
return s.cloneFunc(path, url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *stubGitClient) Pull(path string) error { return nil }
|
||||
func (s *stubGitClient) OriginURL(path string) (string, error) { return "", os.ErrNotExist }
|
||||
|
||||
func writeTestTheme(t *testing.T, fs afero.Fs, registryDir, themeID, name string) {
|
||||
dir := registryDir + "/themes/" + themeID
|
||||
if err := fs.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
themeJSON := `{"id":"` + themeID + `","name":"` + name + `","version":"1.0","author":"a","description":"d"}`
|
||||
if err := afero.WriteFile(fs, dir+"/theme.json", []byte(themeJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMultiRegistry(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
base := "/test-cache"
|
||||
r := &Registry{
|
||||
fs: fs,
|
||||
cacheDir: base,
|
||||
registries: []registries.Source{
|
||||
{Name: "official", URL: "https://example.com/official.git"},
|
||||
{Name: "extra", URL: "https://example.com/extra.git"},
|
||||
},
|
||||
themes: []Theme{},
|
||||
}
|
||||
r.git = &stubGitClient{
|
||||
cloneFunc: func(path string, url string) error {
|
||||
switch path {
|
||||
case base + "/official":
|
||||
writeTestTheme(t, fs, path, "shared", "OfficialShared")
|
||||
writeTestTheme(t, fs, path, "one", "One")
|
||||
case base + "/extra":
|
||||
writeTestTheme(t, fs, path, "shared", "ExtraShared")
|
||||
writeTestTheme(t, fs, path, "two", "Two")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.Update(); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if len(r.themes) != 3 {
|
||||
t.Fatalf("expected 3 themes after dedupe, got %d", len(r.themes))
|
||||
}
|
||||
for _, theme := range r.themes {
|
||||
if theme.ID == "shared" && theme.Name != "OfficialShared" {
|
||||
t.Fatalf("first registry should win for duplicate ID, got %q", theme.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if dir := r.GetThemeDir("two"); dir != base+"/extra/themes/two" {
|
||||
t.Fatalf("expected theme dir under extra registry, got %q", dir)
|
||||
}
|
||||
if path := r.GetThemeSourcePath("one"); path != base+"/official/themes/one/theme.json" {
|
||||
t.Fatalf("expected theme source under official registry, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,134 @@ FocusScope {
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: registriesColumn.implicitHeight + Theme.spacingL * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: Theme.surfaceContainerHigh
|
||||
border.width: 0
|
||||
visible: DMSService.dmsAvailable && DMSService.apiVersion >= 29
|
||||
|
||||
Column {
|
||||
id: registriesColumn
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
spacing: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Registries")
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Sources for plugins and themes. Registries are git repositories with a plugins/ or themes/ directory.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: DMSService.registries
|
||||
|
||||
Item {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
height: registryInfo.implicitHeight + Theme.spacingXS
|
||||
|
||||
Column {
|
||||
id: registryInfo
|
||||
anchors.left: parent.left
|
||||
anchors.right: removeRegistryBtn.left
|
||||
anchors.rightMargin: Theme.spacingM
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingXS
|
||||
|
||||
StyledText {
|
||||
text: modelData.name
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
color: Theme.surfaceText
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("official")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
visible: modelData.official
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: modelData.url
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
font.family: "monospace"
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
DankActionButton {
|
||||
id: removeRegistryBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconName: "delete"
|
||||
iconSize: 18
|
||||
visible: !modelData.official
|
||||
onClicked: DMSService.removeRegistry(modelData.name, response => {
|
||||
if (response.error)
|
||||
ToastService.showError(response.error);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
DankTextField {
|
||||
id: registryNameField
|
||||
width: 140
|
||||
placeholderText: I18n.tr("Name")
|
||||
}
|
||||
|
||||
DankTextField {
|
||||
id: registryUrlField
|
||||
width: parent.width - 140 - addRegistryBtn.width - Theme.spacingM * 2
|
||||
placeholderText: "https://github.com/user/registry.git"
|
||||
}
|
||||
|
||||
DankButton {
|
||||
id: addRegistryBtn
|
||||
text: I18n.tr("Add")
|
||||
enabled: registryNameField.text.trim() !== "" && registryUrlField.text.trim() !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: DMSService.addRegistry(registryNameField.text.trim(), registryUrlField.text.trim(), response => {
|
||||
if (response.error) {
|
||||
ToastService.showError(response.error);
|
||||
return;
|
||||
}
|
||||
registryNameField.text = "";
|
||||
registryUrlField.text = "";
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
|
||||
@@ -517,12 +645,18 @@ FocusScope {
|
||||
function onOperationError(error) {
|
||||
ToastService.showError(error);
|
||||
}
|
||||
function onDmsAvailableChanged() {
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
|
||||
DMSService.listRegistries();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
updateFilteredPlugins();
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 8)
|
||||
DMSService.listInstalled();
|
||||
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
|
||||
DMSService.listRegistries();
|
||||
if (PopoutService.pendingPluginInstall)
|
||||
Qt.callLater(showPluginBrowser);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ Singleton {
|
||||
readonly property int expectedApiVersion: 1
|
||||
property var availablePlugins: []
|
||||
property var installedPlugins: []
|
||||
property var registries: []
|
||||
property var availableThemes: []
|
||||
property var installedThemes: []
|
||||
property bool isConnected: false
|
||||
@@ -479,6 +480,44 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function listRegistries(callback) {
|
||||
sendRequest("registries.list", null, response => {
|
||||
if (response.result) {
|
||||
registries = response.result;
|
||||
}
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addRegistry(name, url, callback) {
|
||||
sendRequest("registries.add", {
|
||||
"name": name,
|
||||
"url": url
|
||||
}, response => {
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
if (!response.error) {
|
||||
listRegistries();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeRegistry(name, callback) {
|
||||
sendRequest("registries.remove", {
|
||||
"name": name
|
||||
}, response => {
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
if (!response.error) {
|
||||
listRegistries();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function listThemes(callback) {
|
||||
sendRequest("themes.list", null, response => {
|
||||
if (response.result) {
|
||||
|
||||
Reference in New Issue
Block a user