1
0
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:
David Mireles
2026-08-06 20:43:44 -06:00
committed by GitHub
parent 630e9bd3cd
commit 3f6cd0b579
14 changed files with 1056 additions and 146 deletions
+1
View File
@@ -704,6 +704,7 @@ func getCommonCommands() []*cobra.Command {
ipcCmd,
debugSrvCmd,
pluginsCmd,
registryCmd,
dank16Cmd,
brightnessCmd,
dpmsCmd,
+67
View File
@@ -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])
},
}
+1
View File
@@ -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)
+98 -44
View File
@@ -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
}
}
+232 -57
View File
@@ -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)
})
}
+130
View File
@@ -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),
})
}
+6
View File
@@ -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")
+1 -1
View File
@@ -36,7 +36,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap"
)
const APIVersion = 28
const APIVersion = 29
var CLIVersion = "dev"
+113 -44
View File
@@ -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 {
+72
View File
@@ -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)
}
}
+134
View File
@@ -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);
}
+39
View File
@@ -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) {