package themes import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "github.com/AvengeMedia/DankMaterialShell/core/internal/registries" "github.com/go-git/go-git/v6" "github.com/spf13/afero" ) type ColorScheme struct { Primary string `json:"primary,omitempty"` PrimaryText string `json:"primaryText,omitempty"` PrimaryContainer string `json:"primaryContainer,omitempty"` Secondary string `json:"secondary,omitempty"` Surface string `json:"surface,omitempty"` SurfaceText string `json:"surfaceText,omitempty"` SurfaceVariant string `json:"surfaceVariant,omitempty"` SurfaceVariantText string `json:"surfaceVariantText,omitempty"` SurfaceTint string `json:"surfaceTint,omitempty"` Background string `json:"background,omitempty"` BackgroundText string `json:"backgroundText,omitempty"` Outline string `json:"outline,omitempty"` SurfaceContainer string `json:"surfaceContainer,omitempty"` SurfaceContainerHigh string `json:"surfaceContainerHigh,omitempty"` SurfaceContainerHighest string `json:"surfaceContainerHighest,omitempty"` Error string `json:"error,omitempty"` Warning string `json:"warning,omitempty"` Info string `json:"info,omitempty"` } type ThemeVariant struct { ID string `json:"id"` Name string `json:"name"` Dark ColorScheme `json:"dark,omitempty"` Light ColorScheme `json:"light,omitempty"` } type ThemeFlavor struct { ID string `json:"id"` Name string `json:"name"` Dark ColorScheme `json:"dark,omitempty"` Light ColorScheme `json:"light,omitempty"` } type ThemeAccent struct { ID string `json:"id"` Name string `json:"name"` FlavorColors map[string]ColorScheme `json:"-"` } func (a *ThemeAccent) UnmarshalJSON(data []byte) error { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } a.FlavorColors = make(map[string]ColorScheme) var mErr error for key, value := range raw { switch key { case "id": mErr = errors.Join(mErr, json.Unmarshal(value, &a.ID)) case "name": mErr = errors.Join(mErr, json.Unmarshal(value, &a.Name)) default: var colors ColorScheme if err := json.Unmarshal(value, &colors); err == nil { a.FlavorColors[key] = colors } else { mErr = errors.Join(mErr, fmt.Errorf("failed to unmarshal flavor colors for key %s: %w", key, err)) } } } return mErr } func (a ThemeAccent) MarshalJSON() ([]byte, error) { m := map[string]any{ "id": a.ID, "name": a.Name, } for k, v := range a.FlavorColors { m[k] = v } return json.Marshal(m) } type MultiVariantDefaults struct { Dark map[string]string `json:"dark,omitempty"` Light map[string]string `json:"light,omitempty"` } type ThemeVariants struct { Type string `json:"type,omitempty"` Default string `json:"default,omitempty"` Defaults *MultiVariantDefaults `json:"defaults,omitempty"` Options []ThemeVariant `json:"options,omitempty"` Flavors []ThemeFlavor `json:"flavors,omitempty"` Accents []ThemeAccent `json:"accents,omitempty"` } type ThemeWCAGGroup struct { Level string `json:"level"` MinRatio float64 `json:"minRatio"` WorstPair []string `json:"worstPair,omitempty"` } type ThemeWCAGBreakdown struct { Name string `json:"name"` Mode string `json:"mode"` Level string `json:"level"` BodyLevel string `json:"bodyLevel"` } type ThemeWCAGMode struct { Level string `json:"level"` MinRatio float64 `json:"minRatio"` WorstPair []string `json:"worstPair,omitempty"` Body *ThemeWCAGGroup `json:"body,omitempty"` Accent *ThemeWCAGGroup `json:"accent,omitempty"` NonText *ThemeWCAGGroup `json:"nonText,omitempty"` Variants map[string]string `json:"variants,omitempty"` Breakdown []ThemeWCAGBreakdown `json:"breakdown,omitempty"` } type ThemeWCAG struct { Level string `json:"level"` Dark *ThemeWCAGMode `json:"dark,omitempty"` Light *ThemeWCAGMode `json:"light,omitempty"` } type Theme struct { ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Author string `json:"author"` Description string `json:"description"` Dark ColorScheme `json:"dark"` Light ColorScheme `json:"light"` Variants *ThemeVariants `json:"variants,omitempty"` WCAG *ThemeWCAG `json:"wcag,omitempty"` PreviewPath string `json:"-"` SourceDir string `json:"sourceDir,omitempty"` } type GitClient interface { PlainClone(path string, url string) error Pull(path string) error OriginURL(path string) (string, error) } type realGitClient struct{} func (g *realGitClient) PlainClone(path string, url string) error { _, err := git.PlainClone(path, &git.CloneOptions{ URL: url, Progress: os.Stdout, }) return err } func (g *realGitClient) Pull(path string) error { repo, err := git.PlainOpen(path) if err != nil { return err } worktree, err := repo.Worktree() if err != nil { return err } err = worktree.Pull(&git.PullOptions{}) if err != nil && err.Error() != "already up-to-date" { return err } return nil } func (g *realGitClient) OriginURL(path string) (string, error) { repo, err := git.PlainOpen(path) if err != nil { return "", err } remote, err := repo.Remote("origin") if err != nil { return "", err } urls := remote.Config().URLs if len(urls) == 0 { return "", errors.New("origin remote has no URL") } return urls[0], nil } type Registry struct { fs afero.Fs cacheDir string registries []registries.Source themes []Theme git GitClient } func NewRegistry() (*Registry, error) { return NewRegistryWithFs(afero.NewOsFs()) } func NewRegistryWithFs(fs afero.Fs) (*Registry, error) { return &Registry{ fs: fs, cacheDir: getCacheDir(), registries: registries.Load(fs), git: &realGitClient{}, }, nil } func (r *Registry) cacheDirFor(src registries.Source) string { return filepath.Join(r.cacheDir, src.Name) } func getCacheDir() string { return filepath.Join(os.TempDir(), "dankdots-plugin-registry") } // A cached clone is reused only when its origin still matches the configured // URL; renamed or re-pointed registries re-clone instead of pulling from the // stale remote. func (r *Registry) updateOne(src registries.Source) error { dir := r.cacheDirFor(src) exists, err := afero.DirExists(r.fs, dir) if err != nil { return fmt.Errorf("failed to check cache directory: %w", err) } if exists { origin, originErr := r.git.OriginURL(dir) if originErr == nil && origin == src.URL && r.git.Pull(dir) == nil { return nil } if err := r.fs.RemoveAll(dir); err != nil { return fmt.Errorf("failed to remove stale registry cache: %w", err) } } 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 } // A registry without a themes/ directory is a valid plugins-only registry. func (r *Registry) loadThemesFrom(dir string) ([]Theme, error) { themesDir := filepath.Join(dir, "themes") entries, err := afero.ReadDir(r.fs, themesDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("failed to read themes directory: %w", err) } var themes []Theme for _, entry := range entries { if !entry.IsDir() { continue } themeDir := filepath.Join(themesDir, entry.Name()) themeFile := filepath.Join(themeDir, "theme.json") data, err := afero.ReadFile(r.fs, themeFile) if err != nil { continue } var theme Theme if err := json.Unmarshal(data, &theme); err != nil { continue } if theme.ID == "" { theme.ID = entry.Name() } theme.SourceDir = entry.Name() theme.WCAG = loadThemeWCAG(r.fs, themeDir) previewPath := filepath.Join(themeDir, "preview.svg") if exists, _ := afero.Exists(r.fs, previewPath); exists { theme.PreviewPath = previewPath } themes = append(themes, theme) } return themes, 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 { data, err := afero.ReadFile(fs, filepath.Join(themeDir, "wcag.json")) if err != nil { return nil } var wcag ThemeWCAG if err := json.Unmarshal(data, &wcag); err != nil { return nil } return &wcag } func (r *Registry) List() ([]Theme, error) { if len(r.themes) == 0 { if err := r.Update(); err != nil && len(r.themes) == 0 { return nil, err } } return SortByFirstParty(r.themes), nil } func (r *Registry) Search(query string) ([]Theme, error) { allThemes, err := r.List() if err != nil { return nil, err } if query == "" { return allThemes, nil } return SortByFirstParty(FuzzySearch(query, allThemes)), nil } func (r *Registry) Get(idOrName string) (*Theme, error) { themes, err := r.List() if err != nil { return nil, err } for _, t := range themes { if t.ID == idOrName { return &t, nil } } for _, t := range themes { if t.Name == idOrName { return &t, nil } } return nil, fmt.Errorf("theme not found: %s", idOrName) } func (r *Registry) GetThemeSourcePath(themeID string) string { // 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 { 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 { return themes }