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:
@@ -704,6 +704,7 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
ipcCmd,
|
ipcCmd,
|
||||||
debugSrvCmd,
|
debugSrvCmd,
|
||||||
pluginsCmd,
|
pluginsCmd,
|
||||||
|
registryCmd,
|
||||||
dank16Cmd,
|
dank16Cmd,
|
||||||
brightnessCmd,
|
brightnessCmd,
|
||||||
dpmsCmd,
|
dpmsCmd,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var registryCmd = &cobra.Command{
|
||||||
|
Use: "registry",
|
||||||
|
Short: "Manage plugin and theme registries",
|
||||||
|
Long: "Manage the registries DMS fetches plugins and themes from. The official registry is always active; additional registries can be added by name and git URL.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var registryListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List configured registries",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||||
|
suffix := ""
|
||||||
|
if s.Official() {
|
||||||
|
suffix = " (official)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s%s\n %s\n", s.Name, suffix, s.URL)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registryAddCmd = &cobra.Command{
|
||||||
|
Use: "add <name> <url>",
|
||||||
|
Short: "Add a registry",
|
||||||
|
Long: "Add a registry by name and git URL. The repository must contain a plugins/ or themes/ directory in the registry format.",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := registries.Add(afero.NewOsFs(), args[0], args[1]); err != nil {
|
||||||
|
log.Fatalf("Error adding registry: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Registry added: %s\n", args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var registryRemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Short: "Remove a registry",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
|
if len(args) != 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, s := range registries.Load(afero.NewOsFs()) {
|
||||||
|
if !s.Official() {
|
||||||
|
names = append(names, s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if err := registries.Remove(afero.NewOsFs(), args[0]); err != nil {
|
||||||
|
log.Fatalf("Error removing registry: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Registry removed: %s\n", args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ func init() {
|
|||||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package registries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OfficialName = "official"
|
||||||
|
officialURL = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source identifies a registry repository. Name doubles as the per-registry
|
||||||
|
// cache subdirectory, so it is restricted to a filesystem-safe slug.
|
||||||
|
type Source struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Source) Official() bool {
|
||||||
|
return s.Name == OfficialName
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||||
|
|
||||||
|
func configPath() (string, error) {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get user config dir: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(configDir, "DankMaterialShell", "registries.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load returns the official registry followed by any user-configured extras.
|
||||||
|
// A missing or unreadable config yields just the official registry.
|
||||||
|
func Load(fs afero.Fs) []Source {
|
||||||
|
sources := []Source{{Name: OfficialName, URL: officialURL}}
|
||||||
|
|
||||||
|
path, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
data, err := afero.ReadFile(fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
var extras []Source
|
||||||
|
if err := json.Unmarshal(data, &extras); err != nil {
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
for _, s := range extras {
|
||||||
|
if !nameRe.MatchString(s.Name) || s.Name == OfficialName || s.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sources = append(sources, s)
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveExtras(fs afero.Fs, extras []Source) error {
|
||||||
|
path, err := configPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(extras, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return afero.WriteFile(fs, path, append(data, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadExtras(fs afero.Fs) []Source {
|
||||||
|
sources := Load(fs)
|
||||||
|
return sources[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func Add(fs afero.Fs, name, url string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
|
||||||
|
if !nameRe.MatchString(name) {
|
||||||
|
return fmt.Errorf("invalid registry name %q: use 1-32 lowercase letters, digits or hyphens", name)
|
||||||
|
}
|
||||||
|
if name == OfficialName {
|
||||||
|
return fmt.Errorf("registry name %q is reserved", OfficialName)
|
||||||
|
}
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("registry URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
extras := loadExtras(fs)
|
||||||
|
for _, s := range extras {
|
||||||
|
if s.Name == name {
|
||||||
|
return fmt.Errorf("registry %q already exists", name)
|
||||||
|
}
|
||||||
|
if s.URL == url {
|
||||||
|
return fmt.Errorf("registry %q already uses this URL", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveExtras(fs, append(extras, Source{Name: name, URL: url}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Remove(fs afero.Fs, name string) error {
|
||||||
|
if name == OfficialName {
|
||||||
|
return fmt.Errorf("the official registry cannot be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
extras := loadExtras(fs)
|
||||||
|
kept := make([]Source, 0, len(extras))
|
||||||
|
for _, s := range extras {
|
||||||
|
if s.Name != name {
|
||||||
|
kept = append(kept, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == len(extras) {
|
||||||
|
return fmt.Errorf("registry %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveExtras(fs, kept)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package registries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFs(t *testing.T) afero.Fs {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "/xdg")
|
||||||
|
return afero.NewMemMapFs()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
fs := setupFs(t)
|
||||||
|
|
||||||
|
sources := Load(fs)
|
||||||
|
require.Len(t, sources, 1)
|
||||||
|
assert.Equal(t, OfficialName, sources[0].Name)
|
||||||
|
assert.Equal(t, officialURL, sources[0].URL)
|
||||||
|
assert.True(t, sources[0].Official())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAndLoad(t *testing.T) {
|
||||||
|
fs := setupFs(t)
|
||||||
|
|
||||||
|
require.NoError(t, Add(fs, "extra", "https://example.com/extra.git"))
|
||||||
|
require.NoError(t, Add(fs, "another", "https://example.com/another.git"))
|
||||||
|
|
||||||
|
sources := Load(fs)
|
||||||
|
require.Len(t, sources, 3)
|
||||||
|
assert.Equal(t, OfficialName, sources[0].Name)
|
||||||
|
assert.Equal(t, "extra", sources[1].Name)
|
||||||
|
assert.Equal(t, "another", sources[2].Name)
|
||||||
|
assert.False(t, sources[1].Official())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddValidation(t *testing.T) {
|
||||||
|
fs := setupFs(t)
|
||||||
|
|
||||||
|
assert.Error(t, Add(fs, "", "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, "Has Spaces", "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, "UPPER", "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, "../escape", "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, OfficialName, "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, "noname", ""))
|
||||||
|
|
||||||
|
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||||
|
assert.Error(t, Add(fs, "extra", "https://example.com/other.git"), "duplicate name rejected")
|
||||||
|
assert.Error(t, Add(fs, "extra2", "https://example.com/x.git"), "duplicate URL rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemove(t *testing.T) {
|
||||||
|
fs := setupFs(t)
|
||||||
|
|
||||||
|
require.NoError(t, Add(fs, "extra", "https://example.com/x.git"))
|
||||||
|
require.NoError(t, Remove(fs, "extra"))
|
||||||
|
assert.Len(t, Load(fs), 1)
|
||||||
|
|
||||||
|
assert.Error(t, Remove(fs, "extra"), "already removed")
|
||||||
|
assert.Error(t, Remove(fs, OfficialName), "official is not removable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadIgnoresInvalidConfig(t *testing.T) {
|
||||||
|
fs := setupFs(t)
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll("/xdg/DankMaterialShell", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte("{not json"), 0o644))
|
||||||
|
assert.Len(t, Load(fs), 1)
|
||||||
|
|
||||||
|
entries := `[{"name":"ok","url":"https://example.com/ok.git"},{"name":"Bad Name","url":"https://example.com/bad.git"},{"name":"official","url":"https://example.com/spoof.git"},{"name":"nourl","url":""}]`
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/xdg/DankMaterialShell/registries.json", []byte(entries), 0o644))
|
||||||
|
sources := Load(fs)
|
||||||
|
require.Len(t, sources, 2, "invalid entries dropped")
|
||||||
|
assert.Equal(t, "ok", sources[1].Name)
|
||||||
|
assert.Equal(t, officialURL, sources[0].URL, "official cannot be spoofed from config")
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package registries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/registries"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistryInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Official bool `json:"official"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuccessResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRequest(conn *models.Conn, req models.Request) {
|
||||||
|
switch req.Method {
|
||||||
|
case "registries.list":
|
||||||
|
HandleList(conn, req)
|
||||||
|
case "registries.add":
|
||||||
|
HandleAdd(conn, req)
|
||||||
|
case "registries.remove":
|
||||||
|
HandleRemove(conn, req)
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleList(conn *models.Conn, req models.Request) {
|
||||||
|
sources := registries.Load(afero.NewOsFs())
|
||||||
|
result := make([]RegistryInfo, len(sources))
|
||||||
|
for i, s := range sources {
|
||||||
|
result[i] = RegistryInfo{Name: s.Name, URL: s.URL, Official: s.Official()}
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAdd(conn *models.Conn, req models.Request) {
|
||||||
|
name, ok := models.Get[string](req, "name")
|
||||||
|
if !ok {
|
||||||
|
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url, ok := models.Get[string](req, "url")
|
||||||
|
if !ok {
|
||||||
|
models.RespondError(conn, req.ID, "missing or invalid 'url' parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := registries.Add(afero.NewOsFs(), name, url); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models.Respond(conn, req.ID, SuccessResult{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("registry added: %s", name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRemove(conn *models.Conn, req models.Request) {
|
||||||
|
name, ok := models.Get[string](req, "name")
|
||||||
|
if !ok {
|
||||||
|
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := registries.Remove(afero.NewOsFs(), name); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
models.Respond(conn, req.ID, SuccessResult{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("registry removed: %s", name),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user