1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-08 14:38:30 -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, ipcCmd,
debugSrvCmd, debugSrvCmd,
pluginsCmd, pluginsCmd,
registryCmd,
dank16Cmd, dank16Cmd,
brightnessCmd, brightnessCmd,
dpmsCmd, 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) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
updateCmd.AddCommand(updateCheckCmd) updateCmd.AddCommand(updateCheckCmd)
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
registryCmd.AddCommand(registryListCmd, registryAddCmd, registryRemoveCmd)
rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(getCommonCommands()...)
rootCmd.AddCommand(authCmd) rootCmd.AddCommand(authCmd)
+98 -44
View File
@@ -2,17 +2,17 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type Plugin struct { type Plugin struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -33,6 +33,7 @@ type Plugin struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
OriginURL(path string) (string, error)
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err 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 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) { func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path) repo, err := git.PlainOpen(path)
if err != nil { if err != nil {
@@ -119,10 +136,11 @@ func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
} }
type Registry struct { type Registry struct {
fs afero.Fs fs afero.Fs
cacheDir string cacheDir string
plugins []Plugin registries []registries.Source
git GitClient plugins []Plugin
git GitClient
} }
func NewRegistry() (*Registry, error) { func NewRegistry() (*Registry, error) {
@@ -130,63 +148,63 @@ func NewRegistry() (*Registry, error) {
} }
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{ return &Registry{
fs: fs, fs: fs,
cacheDir: cacheDir, cacheDir: getCacheDir(),
git: &realGitClient{}, registries: registries.Load(fs),
git: &realGitClient{},
}, nil }, nil
} }
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string { func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry") return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
} }
func (r *Registry) Update() error { // A cached clone is reused only when its origin still matches the configured
exists, err := afero.DirExists(r.fs, r.cacheDir) // 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 { if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err) return fmt.Errorf("failed to check cache directory: %w", err)
} }
if !exists { if exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { origin, originErr := r.git.OriginURL(dir)
return fmt.Errorf("failed to create cache directory: %w", err) if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
} }
if err := r.fs.RemoveAll(dir); err != nil {
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { return fmt.Errorf("failed to remove stale registry cache: %w", err)
return fmt.Errorf("failed to clone registry: %w", err)
}
} else {
// Try to pull, if it fails (e.g., shallow clone corruption), delete and re-clone
if err := r.git.Pull(r.cacheDir); err != nil {
// Repository is likely corrupted or has issues, delete and re-clone
if err := r.fs.RemoveAll(r.cacheDir); err != nil {
return fmt.Errorf("failed to remove corrupted registry: %w", err)
}
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil {
return fmt.Errorf("failed to re-clone registry: %w", err)
}
} }
} }
return r.loadPlugins() 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 { // A registry without a plugins/ directory is a valid themes-only registry.
pluginsDir := filepath.Join(r.cacheDir, "plugins") func (r *Registry) loadPluginsFrom(dir string) ([]Plugin, error) {
pluginsDir := filepath.Join(dir, "plugins")
entries, err := afero.ReadDir(r.fs, pluginsDir) entries, err := afero.ReadDir(r.fs, pluginsDir)
if err != nil { 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 { for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue continue
@@ -206,15 +224,51 @@ func (r *Registry) loadPlugins() error {
plugin.ID = strings.TrimSuffix(entry.Name(), ".json") 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) { func (r *Registry) List() ([]Plugin, error) {
if len(r.plugins) == 0 { if len(r.plugins) == 0 {
if err := r.Update(); err != nil { if err := r.Update(); err != nil && len(r.plugins) == 0 {
return nil, err return nil, err
} }
} }
+232 -57
View File
@@ -2,17 +2,22 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
const testRegistryURL = "https://example.com/test-registry.git"
type mockGitClient struct { type mockGitClient struct {
cloneFunc func(path string, url string) error cloneFunc func(path string, url string) error
pullFunc func(path string) error pullFunc func(path string) error
originFunc func(path string) (string, error)
hasUpdatesFunc func(path string) (bool, string, string, error) hasUpdatesFunc func(path string) (bool, string, string, error)
} }
@@ -30,6 +35,13 @@ func (m *mockGitClient) Pull(path string) error {
return nil 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) { func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil { if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path) return m.hasUpdatesFunc(path)
@@ -42,6 +54,8 @@ func TestNewRegistry(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, registry) assert.NotNil(t, registry)
assert.NotEmpty(t, registry.cacheDir) assert.NotEmpty(t, registry.cacheDir)
require.NotEmpty(t, registry.registries)
assert.Equal(t, registries.OfficialName, registry.registries[0].Name)
} }
func TestGetCacheDir(t *testing.T) { func TestGetCacheDir(t *testing.T) {
@@ -53,10 +67,11 @@ func setupTestRegistry(t *testing.T) (*Registry, afero.Fs, string) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
tmpDir := "/test-cache" tmpDir := "/test-cache"
registry := &Registry{ registry := &Registry{
fs: fs, fs: fs,
cacheDir: tmpDir, cacheDir: tmpDir,
plugins: []Plugin{}, registries: []registries.Source{{Name: "test", URL: testRegistryURL}},
git: &mockGitClient{}, plugins: []Plugin{},
git: &mockGitClient{},
} }
return registry, fs, tmpDir return registry, fs, tmpDir
} }
@@ -104,14 +119,14 @@ func TestLoadPlugins(t *testing.T) {
createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1) createTestPlugin(t, fs, tmpDir, "plugin1.json", plugin1)
createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2) createTestPlugin(t, fs, tmpDir, "plugin2.json", plugin2)
err := registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) 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, "TestPlugin1", plugins[0].Name)
assert.Equal(t, "TestPlugin2", registry.plugins[1].Name) assert.Equal(t, "TestPlugin2", plugins[1].Name)
assert.Equal(t, []string{"dankbar-widget"}, registry.plugins[0].Capabilities) assert.Equal(t, []string{"dankbar-widget"}, plugins[0].Capabilities)
assert.Equal(t, []string{"dep1", "dep2"}, registry.plugins[1].Dependencies) assert.Equal(t, []string{"dep1", "dep2"}, plugins[1].Dependencies)
}) })
t.Run("skips non-json files", func(t *testing.T) { 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) createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, registry.plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) assert.Equal(t, "ValidPlugin", plugins[0].Name)
})
t.Run("skips directories", func(t *testing.T) {
registry, fs, tmpDir := setupTestRegistry(t)
pluginsDir := filepath.Join(tmpDir, "plugins")
err := fs.MkdirAll(filepath.Join(pluginsDir, "subdir"), 0o755)
require.NoError(t, err)
plugin := Plugin{
Name: "ValidPlugin",
Capabilities: []string{"test"},
Category: "test",
Repo: "https://github.com/test/test",
Author: "Test",
Description: "Test",
Compositors: []string{"niri"},
Distro: []string{"any"},
}
createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins()
assert.NoError(t, err)
assert.Len(t, registry.plugins, 1)
}) })
t.Run("skips invalid json files", func(t *testing.T) { 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) createTestPlugin(t, fs, tmpDir, "valid.json", plugin)
err = registry.loadPlugins() plugins, err := registry.loadPluginsFrom(tmpDir)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, registry.plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "ValidPlugin", registry.plugins[0].Name) 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) registry, _, _ := setupTestRegistry(t)
err := registry.loadPlugins() plugins, err := registry.loadPluginsFrom(registry.cacheDir)
assert.Error(t, err) assert.NoError(t, err)
assert.Contains(t, err.Error(), "failed to read plugins directory") assert.Empty(t, plugins)
}) })
} }
@@ -240,19 +231,40 @@ func TestList(t *testing.T) {
Distro: []string{"any"}, Distro: []string{"any"},
} }
mockGit := &mockGitClient{ registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error { cloneFunc: func(path string, url string) error {
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
plugins, err := registry.List() plugins, err := registry.List()
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, plugins, 1) assert.Len(t, plugins, 1)
assert.Equal(t, "NewPlugin", plugins[0].Name) 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) { func TestUpdate(t *testing.T) {
@@ -271,16 +283,15 @@ func TestUpdate(t *testing.T) {
} }
cloneCalled := false cloneCalled := false
mockGit := &mockGitClient{ registry.git = &mockGitClient{
cloneFunc: func(path string, url string) error { cloneFunc: func(path string, url string) error {
cloneCalled = true cloneCalled = true
assert.Equal(t, registryRepo, url) assert.Equal(t, testRegistryURL, url)
assert.Equal(t, tmpDir, path) assert.Equal(t, filepath.Join(tmpDir, "test"), path)
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
err := registry.Update() err := registry.Update()
assert.NoError(t, err) assert.NoError(t, err)
@@ -289,7 +300,7 @@ func TestUpdate(t *testing.T) {
assert.Equal(t, "RepoPlugin", registry.plugins[0].Name) 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) registry, fs, tmpDir := setupTestRegistry(t)
plugin := Plugin{ plugin := Plugin{
@@ -303,24 +314,188 @@ func TestUpdate(t *testing.T) {
Distro: []string{"any"}, Distro: []string{"any"},
} }
err := fs.MkdirAll(tmpDir, 0o755) subdir := filepath.Join(tmpDir, "test")
require.NoError(t, err) require.NoError(t, fs.MkdirAll(subdir, 0o755))
pullCalled := false pullCalled := false
mockGit := &mockGitClient{ registry.git = &mockGitClient{
originFunc: func(path string) (string, error) {
return testRegistryURL, nil
},
pullFunc: func(path string) error { pullFunc: func(path string) error {
pullCalled = true pullCalled = true
assert.Equal(t, tmpDir, path) assert.Equal(t, subdir, path)
createTestPlugin(t, fs, path, "plugin.json", plugin) createTestPlugin(t, fs, path, "plugin.json", plugin)
return nil return nil
}, },
} }
registry.git = mockGit
err = registry.Update() err := registry.Update()
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, pullCalled) assert.True(t, pullCalled)
assert.Len(t, registry.plugins, 1) assert.Len(t, registry.plugins, 1)
assert.Equal(t, "UpdatedPlugin", registry.plugins[0].Name) 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/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" 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/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
@@ -47,6 +48,11 @@ func RouteRequest(conn *models.Conn, req models.Request) {
return return
} }
if strings.HasPrefix(req.Method, "registries.") {
serverRegistries.HandleRequest(conn, req)
return
}
if strings.HasPrefix(req.Method, "theme.auto.") { if strings.HasPrefix(req.Method, "theme.auto.") {
if themeModeManager == nil { if themeModeManager == nil {
models.RespondError(conn, req.ID, "theme mode manager not initialized") models.RespondError(conn, req.ID, "theme mode manager not initialized")
+1 -1
View File
@@ -36,7 +36,7 @@ import (
"github.com/AvengeMedia/dankgo/syncmap" "github.com/AvengeMedia/dankgo/syncmap"
) )
const APIVersion = 28 const APIVersion = 29
var CLIVersion = "dev" var CLIVersion = "dev"
+113 -44
View File
@@ -7,12 +7,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git"
type ColorScheme struct { type ColorScheme struct {
Primary string `json:"primary,omitempty"` Primary string `json:"primary,omitempty"`
PrimaryText string `json:"primaryText,omitempty"` PrimaryText string `json:"primaryText,omitempty"`
@@ -151,6 +150,7 @@ type Theme struct {
type GitClient interface { type GitClient interface {
PlainClone(path string, url string) error PlainClone(path string, url string) error
Pull(path string) error Pull(path string) error
OriginURL(path string) (string, error)
} }
type realGitClient struct{} type realGitClient struct{}
@@ -182,11 +182,28 @@ func (g *realGitClient) Pull(path string) error {
return nil 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 { type Registry struct {
fs afero.Fs fs afero.Fs
cacheDir string cacheDir string
themes []Theme registries []registries.Source
git GitClient themes []Theme
git GitClient
} }
func NewRegistry() (*Registry, error) { func NewRegistry() (*Registry, error) {
@@ -194,61 +211,63 @@ func NewRegistry() (*Registry, error) {
} }
func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { func NewRegistryWithFs(fs afero.Fs) (*Registry, error) {
cacheDir := getCacheDir()
return &Registry{ return &Registry{
fs: fs, fs: fs,
cacheDir: cacheDir, cacheDir: getCacheDir(),
git: &realGitClient{}, registries: registries.Load(fs),
git: &realGitClient{},
}, nil }, nil
} }
func (r *Registry) cacheDirFor(src registries.Source) string {
return filepath.Join(r.cacheDir, src.Name)
}
func getCacheDir() string { func getCacheDir() string {
return filepath.Join(os.TempDir(), "dankdots-plugin-registry") return filepath.Join(os.TempDir(), "dankdots-plugin-registry")
} }
func (r *Registry) Update() error { // A cached clone is reused only when its origin still matches the configured
exists, err := afero.DirExists(r.fs, r.cacheDir) // 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 { if err != nil {
return fmt.Errorf("failed to check cache directory: %w", err) return fmt.Errorf("failed to check cache directory: %w", err)
} }
if !exists { if exists {
if err := r.fs.MkdirAll(filepath.Dir(r.cacheDir), 0o755); err != nil { origin, originErr := r.git.OriginURL(dir)
return fmt.Errorf("failed to create cache directory: %w", err) if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil {
return nil
} }
if err := r.fs.RemoveAll(dir); err != nil {
if err := r.git.PlainClone(r.cacheDir, registryRepo); err != nil { return fmt.Errorf("failed to remove stale registry cache: %w", err)
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)
}
} }
} }
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 { // A registry without a themes/ directory is a valid plugins-only registry.
themesDir := filepath.Join(r.cacheDir, "themes") func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) {
themesDir := filepath.Join(dir, "themes")
entries, err := afero.ReadDir(r.fs, themesDir) entries, err := afero.ReadDir(r.fs, themesDir)
if err != nil { 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 { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
@@ -278,10 +297,46 @@ func (r *Registry) loadThemes() error {
theme.PreviewPath = previewPath 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 { 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) { func (r *Registry) List() ([]Theme, error) {
if len(r.themes) == 0 { if len(r.themes) == 0 {
if err := r.Update(); err != nil { if err := r.Update(); err != nil && len(r.themes) == 0 {
return nil, err return nil, err
} }
} }
@@ -343,11 +398,25 @@ func (r *Registry) Get(idOrName string) (*Theme, error) {
} }
func (r *Registry) GetThemeSourcePath(themeID string) string { 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 { 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 { func SortByFirstParty(themes []Theme) []Theme {
+72
View File
@@ -1,8 +1,10 @@
package themes package themes
import ( import (
"os"
"testing" "testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -64,3 +66,73 @@ func TestLoadThemeWCAGInvalidJSON(t *testing.T) {
t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag) 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 { StyledRect {
width: parent.width width: parent.width
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2) height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
@@ -517,12 +645,18 @@ FocusScope {
function onOperationError(error) { function onOperationError(error) {
ToastService.showError(error); ToastService.showError(error);
} }
function onDmsAvailableChanged() {
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
DMSService.listRegistries();
}
} }
Component.onCompleted: { Component.onCompleted: {
updateFilteredPlugins(); updateFilteredPlugins();
if (DMSService.dmsAvailable && DMSService.apiVersion >= 8) if (DMSService.dmsAvailable && DMSService.apiVersion >= 8)
DMSService.listInstalled(); DMSService.listInstalled();
if (DMSService.dmsAvailable && DMSService.apiVersion >= 29)
DMSService.listRegistries();
if (PopoutService.pendingPluginInstall) if (PopoutService.pendingPluginInstall)
Qt.callLater(showPluginBrowser); Qt.callLater(showPluginBrowser);
} }
+39
View File
@@ -18,6 +18,7 @@ Singleton {
readonly property int expectedApiVersion: 1 readonly property int expectedApiVersion: 1
property var availablePlugins: [] property var availablePlugins: []
property var installedPlugins: [] property var installedPlugins: []
property var registries: []
property var availableThemes: [] property var availableThemes: []
property var installedThemes: [] property var installedThemes: []
property bool isConnected: false 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) { function listThemes(callback) {
sendRequest("themes.list", null, response => { sendRequest("themes.list", null, response => {
if (response.result) { if (response.result) {