From 830ed1a507f4e1f651ac39e96416fed86586742e Mon Sep 17 00:00:00 2001 From: Maarten Tolhuijs Date: Fri, 28 Aug 2026 19:30:49 +0200 Subject: [PATCH] Add installed icon-theme picker --- README.md | 7 + app.go | 73 +++- app_icon_theme_test.go | 150 +++++++ cli/blueprints.go | 23 +- cli/blueprints_ext.go | 10 + cli/cli.go | 1 + cli/generate.go | 47 +- cli/generate_test.go | 80 ++++ cli/icon_theme_test.go | 86 ++++ cli/imports.go | 5 + cli/url_handler.go | 5 + docs/cli.md | 6 + external_import.go | 5 + external_import_test.go | 39 ++ frontend/src/App.svelte | 6 + frontend/src/lib/actions/themeActions.ts | 20 +- .../blueprints/BlueprintsView.svelte | 2 + .../components/blueprints/SaveDialog.svelte | 5 +- .../lib/components/layout/ActionBar.svelte | 23 +- .../sidebar/ColorAdjustments.svelte | 24 +- .../components/sidebar/IconThemePicker.svelte | 269 ++++++++++++ .../sidebar/IconThemePreview.svelte | 88 ++++ .../components/sidebar/SettingsSidebar.svelte | 5 + .../components/sidebar/TemplateToggles.svelte | 4 +- frontend/src/lib/stores/history.svelte.ts | 44 +- frontend/src/lib/stores/theme.svelte.ts | 33 +- frontend/src/lib/types/theme.ts | 15 + frontend/tests/icon-theme.test.mjs | 77 ++++ frontend/wailsjs/go/main/App.d.ts | 21 +- frontend/wailsjs/go/main/App.js | 20 +- frontend/wailsjs/go/models.ts | 200 +++++++++ internal/blueprint/icon_theme_test.go | 111 +++++ internal/blueprint/model.go | 31 +- internal/blueprint/validate.go | 7 + internal/icontheme/catalog.go | 402 ++++++++++++++++++ internal/icontheme/catalog_test.go | 333 +++++++++++++++ internal/icontheme/metadata.go | 202 +++++++++ internal/icontheme/model.go | 84 ++++ internal/icontheme/model_test.go | 118 +++++ internal/icontheme/preview.go | 381 +++++++++++++++++ internal/icontheme/preview_test.go | 302 +++++++++++++ internal/icontheme/security_test.go | 318 ++++++++++++++ internal/theme/history_test.go | 24 ++ internal/theme/state.go | 5 + internal/theme/state_test.go | 18 +- internal/theme/writer.go | 176 +++++++- internal/theme/writer_icon_theme_test.go | 185 ++++++++ 47 files changed, 4020 insertions(+), 70 deletions(-) create mode 100644 app_icon_theme_test.go create mode 100644 cli/icon_theme_test.go create mode 100644 frontend/src/lib/components/sidebar/IconThemePicker.svelte create mode 100644 frontend/src/lib/components/sidebar/IconThemePreview.svelte create mode 100644 frontend/tests/icon-theme.test.mjs create mode 100644 internal/blueprint/icon_theme_test.go create mode 100644 internal/icontheme/catalog.go create mode 100644 internal/icontheme/catalog_test.go create mode 100644 internal/icontheme/metadata.go create mode 100644 internal/icontheme/model.go create mode 100644 internal/icontheme/model_test.go create mode 100644 internal/icontheme/preview.go create mode 100644 internal/icontheme/preview_test.go create mode 100644 internal/icontheme/security_test.go create mode 100644 internal/theme/history_test.go create mode 100644 internal/theme/writer_icon_theme_test.go diff --git a/README.md b/README.md index 1783c6f3..6052e723 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ A visual theming application for [Omarchy](https://omarchy.org). Extract colors - Import 250+ community Base16 color schemes - Save and restore complete themes as blueprint files - Export themes as shareable packages with selective app inclusion +- Keep the palette-matched Yaru icon default or choose an installed desktop icon theme per blueprint ### Application Support - 20+ pre-configured apps: Hyprland, Waybar, Kitty, Alacritty, Ghostty, Neovim, VS Code, Zed, btop, and more @@ -80,6 +81,12 @@ cd aether && make build 3. Adjust colors as needed 4. Click **Apply Theme** +### Desktop Icon Themes + +Use the compact **Icons** control directly below **Light mode** in the editor sidebar. Its switch enables or disables the existing Icons target, while the selection opens the installed-theme chooser. **Automatic** preserves Aether's color-matched Yaru output. An explicit choice writes the installed theme's directory ID; if that theme is later uninstalled, Aether keeps the saved ID and marks it as missing instead of silently replacing it. Disabling Icons omits `icons.theme` without erasing the choice. + +The picker searches the standard user and system XDG icon roots and the legacy `~/.icons` root. **Refresh** rescans after you install a theme. Aether does not download or install icon themes. + ## CLI ```bash diff --git a/app.go b/app.go index 3a12dff3..8daca5ab 100644 --- a/app.go +++ b/app.go @@ -16,6 +16,7 @@ import ( "aether/internal/color" "aether/internal/extraction" "aether/internal/favorites" + "aether/internal/icontheme" "aether/internal/omarchy" "aether/internal/platform" "aether/internal/template" @@ -39,6 +40,7 @@ type App struct { favorites *favorites.Service wallhaven *wallhaven.Client batch *batch.Processor + iconThemes *icontheme.Catalog themeWatcher *theme.ThemeWatcher ipcServer *ipc.Server pending pendingImportState @@ -96,10 +98,26 @@ func NewApp() *App { favorites: favorites.NewService(), wallhaven: wallhaven.NewClient(), batch: batch.NewProcessor(), + iconThemes: icontheme.NewCatalog(), themeWatcher: theme.NewThemeWatcher(), } } +// ListInstalledIconThemes returns the cached installed desktop icon themes. +func (a *App) ListInstalledIconThemes() ([]icontheme.ThemeSummary, error) { + return a.iconThemes.List(context.Background()) +} + +// RefreshInstalledIconThemes rescans approved icon roots. +func (a *App) RefreshInstalledIconThemes() ([]icontheme.ThemeSummary, error) { + return a.iconThemes.Refresh(context.Background()) +} + +// GetIconThemePreview returns safe backend-rasterized samples for a theme ID. +func (a *App) GetIconThemePreview(themeID string) (icontheme.ThemePreview, error) { + return a.iconThemes.Preview(context.Background(), themeID) +} + // newSeededState builds a ThemeState with the unmodified DefaultPalette // and no wallpaper, so first launch (and ResetState) lands on the empty // editor that walks new users through extracting and applying a theme. @@ -226,12 +244,17 @@ type SyncStateRequest struct { ExtendedColors map[string]string `json:"extendedColors"` AppOverrides map[string]map[string]string `json:"appOverrides"` AdditionalImages []string `json:"additionalImages"` + IconTheme icontheme.Selection `json:"iconTheme"` } // SyncState is called (debounced) by the frontend whenever the editor state // changes. Uses SetAdjustedPalette so BasePalette from the last extraction // is preserved as the "pristine" reference. -func (a *App) SyncState(req SyncStateRequest) { +func (a *App) SyncState(req SyncStateRequest) error { + iconTheme, err := icontheme.NormalizeSelection(req.IconTheme) + if err != nil { + return fmt.Errorf("iconTheme: %w", err) + } if len(req.Palette) >= 16 { var p [16]string for i := 0; i < 16; i++ { @@ -250,6 +273,8 @@ func (a *App) SyncState(req SyncStateRequest) { if req.AdditionalImages != nil { a.state.AdditionalImages = req.AdditionalImages } + a.state.IconTheme = iconTheme + return nil } // ANSI 16-color palette positions. Names follow xterm convention; the role @@ -326,6 +351,7 @@ type ApplyThemeRequest struct { ExtendedColors map[string]string `json:"extendedColors"` Settings theme.Settings `json:"settings"` AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // ApplyTheme processes all templates and applies the theme to the system. @@ -345,6 +371,7 @@ func (a *App) ApplyTheme(req ApplyThemeRequest) (*theme.ApplyResult, error) { ExtendedColors: req.ExtendedColors, AdditionalImages: req.AdditionalImages, AppOverrides: appOverrides, + IconTheme: req.IconTheme, } return a.writer.ApplyTheme(state, req.Settings) @@ -362,6 +389,7 @@ type SaveAndApplyThemeRequest struct { ExtendedColors map[string]string `json:"extendedColors"` Settings theme.Settings `json:"settings"` AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // SaveAndApplyTheme writes a reusable named theme folder before applying it. @@ -385,6 +413,7 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul ExtendedColors: req.ExtendedColors, AdditionalImages: req.AdditionalImages, AppOverrides: req.AppOverrides, + IconTheme: req.IconTheme, } if state.AppOverrides == nil { state.AppOverrides = make(map[string]map[string]string) @@ -440,6 +469,10 @@ func (a *App) ListBlueprints() ([]map[string]interface{}, error) { // Convert to raw maps to avoid Wails model conversion issues result := make([]map[string]interface{}, len(bps)) for i, bp := range bps { + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return nil, fmt.Errorf("blueprint %q icon theme: %w", bp.Name, err) + } result[i] = map[string]interface{}{ "name": bp.Name, "timestamp": bp.Timestamp, @@ -452,6 +485,7 @@ func (a *App) ListBlueprints() ([]map[string]interface{}, error) { }, "adjustments": bp.Adjustments, "appOverrides": bp.AppOverrides, + "iconTheme": iconTheme, } } return result, nil @@ -469,6 +503,7 @@ type SaveBlueprintRequest struct { ExtendedColors map[string]string `json:"extendedColors"` AppOverrides map[string]map[string]string `json:"appOverrides"` Adjustments map[string]float64 `json:"adjustments"` + IconTheme icontheme.Selection `json:"iconTheme"` } func (a *App) SaveBlueprint(req SaveBlueprintRequest) error { @@ -484,6 +519,9 @@ func (a *App) SaveBlueprint(req SaveBlueprintRequest) error { Adjustments: req.Adjustments, AppOverrides: req.AppOverrides, } + if err := bp.SetIconThemeSelection(req.IconTheme); err != nil { + return fmt.Errorf("iconTheme: %w", err) + } return a.blueprints.Save(req.Name, bp) } @@ -543,6 +581,11 @@ func (a *App) LoadBlueprint(name string) error { } a.state.Adjustments = a.adjustmentsFromBlueprint(bp) a.state.AppOverrides = a.appOverridesFromBlueprint(bp) + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return fmt.Errorf("blueprint iconTheme: %w", err) + } + a.state.IconTheme = iconTheme return nil } @@ -577,6 +620,11 @@ func (a *App) ApplyBlueprint(name string) (*theme.ApplyResult, error) { } a.state.Adjustments = a.adjustmentsFromBlueprint(bp) a.state.AppOverrides = a.appOverridesFromBlueprint(bp) + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return nil, fmt.Errorf("blueprint iconTheme: %w", err) + } + a.state.IconTheme = iconTheme return a.writer.ApplyTheme(a.state, theme.DefaultApplySettings()) } @@ -942,6 +990,7 @@ type ExportThemeRequest struct { ExtendedColors map[string]string `json:"extendedColors"` InstallToOmarchy bool `json:"installToOmarchy"` AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // allExportableApps is the full set of app names that can be exported. @@ -987,6 +1036,7 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) { ExtendedColors: req.ExtendedColors, AdditionalImages: req.AdditionalImages, AppOverrides: exportOverrides, + IconTheme: req.IconTheme, } // Build included set from the request @@ -1029,12 +1079,13 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) { // ImportResult is returned by ImportFileDialog with the imported colors. type ImportResult struct { - Colors []string `json:"colors"` - ExtendedColors map[string]string `json:"extendedColors"` - Name string `json:"name"` - Path string `json:"path"` - WallpaperPath string `json:"wallpaperPath"` - LightMode bool `json:"lightMode"` + Colors []string `json:"colors"` + ExtendedColors map[string]string `json:"extendedColors"` + Name string `json:"name"` + Path string `json:"path"` + WallpaperPath string `json:"wallpaperPath"` + LightMode bool `json:"lightMode"` + IconTheme icontheme.Selection `json:"iconTheme"` } // ImportFileDialog opens a file dialog for importing a theme file. @@ -1119,6 +1170,11 @@ func (a *App) importFile(path, fileType string) (*ImportResult, error) { a.state.SetPalette(palette) a.state.WallpaperPath = a.resolveWallpaper(bp.Palette) a.state.LightMode = bp.Palette.LightMode + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return nil, fmt.Errorf("iconTheme: %w", err) + } + a.state.IconTheme = iconTheme log.Printf("[import] success: %s (%d colors)", bp.Name, len(bp.Palette.Colors)) return &ImportResult{ @@ -1128,6 +1184,7 @@ func (a *App) importFile(path, fileType string) (*ImportResult, error) { Path: savedPath, WallpaperPath: a.state.WallpaperPath, LightMode: a.state.LightMode, + IconTheme: a.state.IconTheme, }, nil } @@ -1276,6 +1333,7 @@ func (a *App) HandleIPC(req ipc.Request) ipc.Response { LightMode: a.state.LightMode, ExtendedColors: a.state.ExtendedColors, AppOverrides: a.state.AppOverrides, + IconTheme: a.state.IconTheme, }) if err != nil { return ipc.Response{OK: false, Error: err.Error()} @@ -1367,6 +1425,7 @@ func (a *App) emitIPCStateChanged() { "mode": a.state.ExtractionMode, "wallpaper": a.state.WallpaperPath, "adjustments": a.state.Adjustments, + "iconTheme": a.state.IconTheme, }) } diff --git a/app_icon_theme_test.go b/app_icon_theme_test.go new file mode 100644 index 00000000..5af64996 --- /dev/null +++ b/app_icon_theme_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "aether/internal/blueprint" + "aether/internal/icontheme" + "aether/internal/theme" +) + +func TestInstalledIconThemeWailsBoundary(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeAppTestIconTheme(t, root, "One", "One") + app := &App{iconThemes: icontheme.NewCatalogWithRoots([]icontheme.Root{{ + Path: root, Origin: icontheme.OriginUser, + }})} + + first, err := app.ListInstalledIconThemes() + if err != nil { + t.Fatal(err) + } + if len(first) != 1 || first[0].ID != "One" { + t.Fatalf("ListInstalledIconThemes() = %#v, want One", first) + } + writeAppTestIconTheme(t, root, "Two", "Two") + cached, err := app.ListInstalledIconThemes() + if err != nil { + t.Fatal(err) + } + if len(cached) != 1 { + t.Errorf("cached list has %d themes, want 1", len(cached)) + } + refreshed, err := app.RefreshInstalledIconThemes() + if err != nil { + t.Fatal(err) + } + if len(refreshed) != 2 { + t.Errorf("refreshed list has %d themes, want 2", len(refreshed)) + } + + preview, err := app.GetIconThemePreview("One") + if err != nil { + t.Fatal(err) + } + if preview.ThemeID != "One" { + t.Errorf("preview ThemeID = %q, want One", preview.ThemeID) + } + if _, err := app.GetIconThemePreview("../escape"); err == nil { + t.Fatal("GetIconThemePreview accepted a path-like ID") + } + encoded, err := json.Marshal(struct { + Themes []icontheme.ThemeSummary `json:"themes"` + Preview icontheme.ThemePreview `json:"preview"` + }{Themes: refreshed, Preview: preview}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), root) || strings.Contains(string(encoded), "path") { + t.Errorf("Wails DTO leaked a path: %s", encoded) + } +} + +func TestSyncStateValidatesAndMirrorsIconTheme(t *testing.T) { + app := &App{state: theme.NewThemeState()} + want := (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"}) + if err := app.SyncState(SyncStateRequest{IconTheme: want}); err != nil { + t.Fatal(err) + } + if app.state.IconTheme != want { + t.Errorf("synced IconTheme = %+v, want %+v", app.state.IconTheme, want) + } + if err := app.SyncState(SyncStateRequest{ + IconTheme: icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "../escape"}, + }); err == nil { + t.Fatal("SyncState accepted an unsafe icon theme") + } + if app.state.IconTheme != want { + t.Errorf("unsafe sync changed IconTheme to %+v, want preserved %+v", app.state.IconTheme, want) + } +} + +func TestLoadAndListBlueprintPreserveExplicitIconTheme(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + path := filepath.Join(t.TempDir(), "portable.json") + data := fmt.Sprintf( + `{"name":"Portable","palette":{"colors":%s},"iconTheme":{"mode":"explicit","id":"Missing-But-Safe"}}`, + validAppTestPaletteJSON(), + ) + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + bp, err := blueprint.ImportJSON(path) + if err != nil { + t.Fatal(err) + } + if _, err := blueprint.SaveImported(bp); err != nil { + t.Fatal(err) + } + + app := NewApp() + if err := app.LoadBlueprint("Portable"); err != nil { + t.Fatal(err) + } + want := (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"}) + if app.state.IconTheme != want { + t.Errorf("loaded state IconTheme = %+v, want %+v", app.state.IconTheme, want) + } + + listed, err := app.ListBlueprints() + if err != nil { + t.Fatal(err) + } + if len(listed) != 1 { + t.Fatalf("listed %d blueprints, want 1", len(listed)) + } + if got, ok := listed[0]["iconTheme"].(icontheme.Selection); !ok || got != want { + t.Errorf("listed iconTheme = %#v (%T), want %+v", listed[0]["iconTheme"], listed[0]["iconTheme"], want) + } +} + +func validAppTestPaletteJSON() string { + colors := make([]string, 16) + for i := range colors { + colors[i] = fmt.Sprintf("#%06x", i) + } + data, _ := json.Marshal(colors) + return string(data) +} + +func writeAppTestIconTheme(t *testing.T, root, id, name string) { + t.Helper() + dir := filepath.Join(root, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("[Icon Theme]\nName=%s\n", name) + if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/cli/blueprints.go b/cli/blueprints.go index 09329479..86301a1e 100644 --- a/cli/blueprints.go +++ b/cli/blueprints.go @@ -7,6 +7,7 @@ import ( "sort" "aether/internal/blueprint" + "aether/internal/icontheme" "aether/internal/platform" "aether/internal/theme" "aether/internal/wallhaven" @@ -33,20 +34,26 @@ func runListBlueprints(args []string) int { if jsonOut { type entry struct { - Name string `json:"name"` - Colors []string `json:"colors"` - LightMode bool `json:"lightMode"` - Wallpaper string `json:"wallpaper,omitempty"` - Timestamp int64 `json:"timestamp"` + Name string `json:"name"` + Colors []string `json:"colors"` + LightMode bool `json:"lightMode"` + Wallpaper string `json:"wallpaper,omitempty"` + Timestamp int64 `json:"timestamp"` + IconTheme icontheme.Selection `json:"iconTheme"` } out := make([]entry, len(blueprints)) for i, bp := range blueprints { + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return printErrorJSON(fmt.Sprintf("Blueprint %q has invalid iconTheme: %v", bp.Name, err)) + } out[i] = entry{ Name: bp.Name, Colors: bp.Palette.Colors, LightMode: bp.Palette.LightMode, Wallpaper: bp.Palette.Wallpaper, Timestamp: bp.Timestamp, + IconTheme: iconTheme, } } return printJSON(map[string]interface{}{ @@ -126,6 +133,11 @@ func runApplyBlueprint(args []string, templatesFS embed.FS) int { wallpaperPath := resolveWallpaperCLI(bp.Palette) writer := theme.NewWriter(templatesFS, "templates") + iconTheme, err := bp.IconThemeSelection() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Blueprint iconTheme: %v\n", err) + return 1 + } state := &theme.ThemeState{ Palette: palette, WallpaperPath: wallpaperPath, @@ -134,6 +146,7 @@ func runApplyBlueprint(args []string, templatesFS embed.FS) int { ExtendedColors: bp.Palette.ExtendedColors, AppOverrides: bp.AppOverrides, AdditionalImages: bp.Palette.AdditionalImages, + IconTheme: iconTheme, } settings := theme.DefaultApplySettings() diff --git a/cli/blueprints_ext.go b/cli/blueprints_ext.go index 24693647..968b2ed8 100644 --- a/cli/blueprints_ext.go +++ b/cli/blueprints_ext.go @@ -47,6 +47,16 @@ func runShowBlueprint(args []string) int { fmt.Printf("Blueprint: %s\n", bp.Name) fmt.Printf(" Timestamp: %d\n", bp.Timestamp) fmt.Printf(" Light mode: %v\n", bp.Palette.LightMode) + iconTheme, err := bp.IconThemeSelection() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Blueprint iconTheme: %v\n", err) + return 1 + } + if iconTheme.Mode == "explicit" { + fmt.Printf(" Icon theme: %s\n", iconTheme.ID) + } else { + fmt.Println(" Icon theme: Automatic") + } if bp.Palette.Wallpaper != "" { fmt.Printf(" Wallpaper: %s\n", bp.Palette.Wallpaper) } diff --git a/cli/cli.go b/cli/cli.go index e1604a16..18101074 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -133,6 +133,7 @@ Theme generation: --light-mode Generate light variant --no-apply Render templates without activating --output Output dir (defaults to ~/.config/aether/theme) + --icon-theme automatic| Use color-matched Yaru or an explicit installed theme ID --no-zed Skip Zed extension (default on) --no-vscode Skip VSCode integration (default on) --no-neovim Skip Neovim template (default on) diff --git a/cli/generate.go b/cli/generate.go index 07d3b48c..a030b928 100644 --- a/cli/generate.go +++ b/cli/generate.go @@ -4,11 +4,50 @@ import ( "embed" "fmt" "os" + "strings" "aether/internal/extraction" + "aether/internal/icontheme" "aether/internal/theme" ) +func parseIconThemeOption(args []string) (icontheme.Selection, []string, error) { + selection := icontheme.Automatic() + remaining := make([]string, 0, len(args)) + found := false + + for i := 0; i < len(args); i++ { + arg := args[i] + if arg != "--icon-theme" { + remaining = append(remaining, arg) + continue + } + if found { + return icontheme.Selection{}, args, fmt.Errorf("--icon-theme may only be specified once") + } + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + return icontheme.Selection{}, args, fmt.Errorf("--icon-theme requires automatic or a theme ID") + } + + value := args[i+1] + if value != "automatic" { + var err error + selection, err = icontheme.NormalizeSelection(icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: value, + }) + if err != nil { + return icontheme.Selection{}, args, err + } + } + + found = true + i++ + } + + return selection, remaining, nil +} + func runGenerate(args []string, templatesFS embed.FS) int { for _, arg := range args { if arg == "--gtk" || arg == "--no-gtk" { @@ -16,6 +55,11 @@ func runGenerate(args []string, templatesFS embed.FS) int { return 1 } } + iconTheme, args, err := parseIconThemeOption(args) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Invalid icon theme: %v\n", err) + return 1 + } // Parse flags mode, args := parseFlag(args, "--extract-mode") @@ -33,7 +77,7 @@ func runGenerate(args []string, templatesFS embed.FS) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "Error: Wallpaper path is required") - fmt.Fprintln(os.Stderr, "Usage: aether --generate [--extract-mode ] [--light-mode] [--no-apply] [--output ] [--no-zed] [--no-vscode] [--no-neovim]") + fmt.Fprintln(os.Stderr, "Usage: aether --generate [--extract-mode ] [--light-mode] [--no-apply] [--output ] [--icon-theme automatic|] [--no-zed] [--no-vscode] [--no-neovim]") return 1 } wallpaperPath := args[0] @@ -97,6 +141,7 @@ func runGenerate(args []string, templatesFS embed.FS) int { WallpaperPath: wallpaperPath, LightMode: lightMode, ColorRoles: colorRoles, + IconTheme: iconTheme, } settings := theme.Settings{ diff --git a/cli/generate_test.go b/cli/generate_test.go index bdd9b20b..217fd58c 100644 --- a/cli/generate_test.go +++ b/cli/generate_test.go @@ -4,8 +4,11 @@ import ( "embed" "io" "os" + "reflect" "strings" "testing" + + "aether/internal/icontheme" ) func TestRunGenerateRejectsRemovedGTKOptions(t *testing.T) { @@ -27,6 +30,83 @@ func TestRunGenerateRejectsRemovedGTKOptions(t *testing.T) { } } +func TestParseIconThemeOption(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want icontheme.Selection + remaining []string + wantErr bool + }{ + { + name: "omitted is automatic", + args: []string{"wallpaper.png", "--no-apply"}, + want: icontheme.Automatic(), + remaining: []string{"wallpaper.png", "--no-apply"}, + }, + { + name: "explicit automatic", + args: []string{"wallpaper.png", "--icon-theme", "automatic", "--no-apply"}, + want: icontheme.Automatic(), + remaining: []string{"wallpaper.png", "--no-apply"}, + }, + { + name: "explicit safe missing ID", + args: []string{"wallpaper.png", "--icon-theme", "Missing-But-Safe", "--no-apply"}, + want: icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Missing-But-Safe", + }, + remaining: []string{"wallpaper.png", "--no-apply"}, + }, + { + name: "unsafe ID", + args: []string{"wallpaper.png", "--icon-theme", "../escape"}, + wantErr: true, + }, + { + name: "missing value", + args: []string{"wallpaper.png", "--icon-theme"}, + wantErr: true, + }, + { + name: "another option is not a value", + args: []string{"wallpaper.png", "--icon-theme", "--no-apply"}, + wantErr: true, + }, + { + name: "duplicate option", + args: []string{"wallpaper.png", "--icon-theme", "Papirus", "--icon-theme", "Yaru"}, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, remaining, err := parseIconThemeOption(tt.args) + if tt.wantErr { + if err == nil { + t.Fatalf("parseIconThemeOption(%q) = %+v, %q, nil; want error", tt.args, got, remaining) + } + return + } + if err != nil { + t.Fatalf("parseIconThemeOption(%q): %v", tt.args, err) + } + if got != tt.want { + t.Errorf("selection = %+v, want %+v", got, tt.want) + } + if !reflect.DeepEqual(remaining, tt.remaining) { + t.Errorf("remaining = %q, want %q", remaining, tt.remaining) + } + }) + } +} + func captureGenerateStderr(t *testing.T, run func() int) (int, string) { t.Helper() r, w, err := os.Pipe() diff --git a/cli/icon_theme_test.go b/cli/icon_theme_test.go new file mode 100644 index 00000000..e2df6200 --- /dev/null +++ b/cli/icon_theme_test.go @@ -0,0 +1,86 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "aether/internal/blueprint" + "aether/internal/icontheme" + "aether/internal/pending" +) + +func TestShowBlueprintPrintsIconTheme(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + colors := make([]string, 16) + for i := range colors { + colors[i] = "#123456" + } + bp := blueprint.Blueprint{Palette: blueprint.PaletteData{Colors: colors}} + if err := bp.SetIconThemeSelection(icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Missing-But-Safe", + }); err != nil { + t.Fatal(err) + } + if err := blueprint.NewService().Save("Portable", bp); err != nil { + t.Fatal(err) + } + + code, output := captureStdout(t, func() int { + return runShowBlueprint([]string{"Portable"}) + }) + if code != 0 { + t.Fatalf("runShowBlueprint() = %d, output %q", code, output) + } + if !strings.Contains(output, "Icon theme: Missing-But-Safe") { + t.Errorf("show output = %q, want explicit icon theme", output) + } +} + +func TestBuildURLImportStatePreservesSafeMissingIconTheme(t *testing.T) { + path := filepath.Join(t.TempDir(), "portable.json") + data := `{ + "name":"Portable", + "palette":{"colors":["#000000","#111111","#222222","#333333","#444444","#555555","#666666","#777777","#888888","#999999","#aaaaaa","#bbbbbb","#cccccc","#dddddd","#eeeeee","#ffffff"]}, + "iconTheme":{"mode":"explicit","id":"Missing-But-Safe"} + }` + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + state, err := buildURLImportState(&pending.Import{ExternalTheme: path}) + if err != nil { + t.Fatal(err) + } + want := (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"}) + if state.IconTheme != want { + t.Errorf("URL import IconTheme = %+v, want %+v", state.IconTheme, want) + } +} + +func captureStdout(t *testing.T, run func() int) (int, string) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + original := os.Stdout + os.Stdout = w + defer func() { os.Stdout = original }() + code := run() + if err := w.Close(); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + return code, string(data) +} diff --git a/cli/imports.go b/cli/imports.go index 98653857..84d3fc45 100644 --- a/cli/imports.go +++ b/cli/imports.go @@ -51,6 +51,11 @@ func applyImportedTheme(templatesFS embed.FS, bp *blueprint.Blueprint, palette [ state.ExtendedColors[k] = v } state.SetPalette(palette) + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return nil, fmt.Errorf("blueprint iconTheme: %w", err) + } + state.IconTheme = iconTheme return writer.ApplyTheme(state, theme.DefaultApplySettings()) } diff --git a/cli/url_handler.go b/cli/url_handler.go index 3f7727d0..336a6845 100644 --- a/cli/url_handler.go +++ b/cli/url_handler.go @@ -221,6 +221,11 @@ func buildURLImportState(imp *pending.Import) (*theme.ThemeState, error) { var palette [16]string copy(palette[:], bp.Palette.Colors) state.SetPalette(palette) + iconTheme, err := bp.IconThemeSelection() + if err != nil { + return nil, fmt.Errorf("blueprint iconTheme: %w", err) + } + state.IconTheme = iconTheme switch imp.Mode { case "light": diff --git a/docs/cli.md b/docs/cli.md index e5b512e7..db05e493 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -59,6 +59,7 @@ aether --generate /path/to/wallpaper.jpg | `--extract-mode MODE` | Color extraction algorithm (see below) | | `--no-apply` | Generate templates only, don't activate theme | | `--output PATH` | Custom output directory (use with `--no-apply`) | +| `--icon-theme automatic\|ID` | Keep color-matched Yaru or write an explicit safe icon-theme directory ID | **Extraction Modes:** @@ -85,8 +86,13 @@ aether --generate ~/wallpaper.jpg --no-apply # Generate to custom directory for use with external scripts aether --generate ~/wallpaper.jpg --no-apply --output ~/my-themes/generated + +# Generate with a specific installed icon theme (installation is not required on this machine) +aether --generate ~/wallpaper.jpg --no-apply --icon-theme Papirus-Dark ``` +Omitting `--icon-theme`, or passing `--icon-theme automatic`, preserves Aether's existing palette-derived Yaru behavior. A safe explicit ID is written exactly even when it is not installed locally, so portable blueprints keep their intent. Excluding the Icons target still takes precedence and omits `icons.theme`. + ### Import Blueprint Import a theme from URL or local file: diff --git a/external_import.go b/external_import.go index c3d1c1e7..48d3b953 100644 --- a/external_import.go +++ b/external_import.go @@ -169,6 +169,11 @@ func (a *App) stageImportIntoState(expectedSourceURL string) (*pending.Import, e palette[i] = bp.Palette.Colors[i] } a.state.SetPalette(palette) + iconTheme, iconThemeErr := bp.IconThemeSelection() + if iconThemeErr != nil { + return nil, fmt.Errorf("import iconTheme: %w", iconThemeErr) + } + a.state.IconTheme = iconTheme } if imp.Wallpaper != "" { diff --git a/external_import_test.go b/external_import_test.go index 85865c8e..5308e4dc 100644 --- a/external_import_test.go +++ b/external_import_test.go @@ -1,8 +1,13 @@ package main import ( + "encoding/json" + "fmt" + "os" + "path/filepath" "testing" + "aether/internal/icontheme" "aether/internal/pending" ) @@ -20,3 +25,37 @@ func TestStageImportRejectsStaleConfirmation(t *testing.T) { t.Fatal("stale confirmation replaced the latest pending import") } } + +func TestStageExternalBlueprintPreservesExplicitIconTheme(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + colors := make([]string, 16) + for i := range colors { + colors[i] = fmt.Sprintf("#%06x", i) + } + palette, err := json.Marshal(colors) + if err != nil { + t.Fatal(err) + } + themePath := filepath.Join(t.TempDir(), "external.json") + data := fmt.Sprintf( + `{"name":"External","palette":{"colors":%s},"iconTheme":{"mode":"explicit","id":"Missing-But-Safe"}}`, + palette, + ) + if err := os.WriteFile(themePath, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + + app := NewApp() + request := &pending.Import{SourceURL: "aether://test", ExternalTheme: themePath} + app.pending.curr = request + if _, err := app.stageImportIntoState(request.SourceURL); err != nil { + t.Fatal(err) + } + want := (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"}) + if app.state.IconTheme != want { + t.Errorf("staged IconTheme = %+v, want %+v", app.state.IconTheme, want) + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e03ea2fb..73469c22 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -64,6 +64,7 @@ getThemeSnapshot, getThemeSignature, setLastExtractedPath, + setIconTheme, } from '$lib/stores/theme.svelte'; import {debounce} from '$lib/utils/debounce'; import {STORAGE_KEYS} from '$lib/constants/storage'; @@ -193,6 +194,7 @@ if (s?.extendedColors) { setExtendedColors(s.extendedColors); } + setIconTheme(s?.iconTheme, true); if (s?.wallpaperPath) { setWallpaperPath(s.wallpaperPath); // Treat the restored wallpaper as already-extracted so a @@ -422,6 +424,7 @@ mode?: string; wallpaper?: string; adjustments?: import('$lib/types/theme').Adjustments; + iconTheme?: import('$lib/types/theme').IconThemeSelection; }) => { if (state.palette && state.palette.length >= 16) { setPalette(state.palette); @@ -441,6 +444,9 @@ if (state.adjustments) { setAdjustments(state.adjustments); } + if (state.iconTheme) { + setIconTheme(state.iconTheme, true); + } } ); } catch {} diff --git a/frontend/src/lib/actions/themeActions.ts b/frontend/src/lib/actions/themeActions.ts index afc94ca9..c36366e1 100644 --- a/frontend/src/lib/actions/themeActions.ts +++ b/frontend/src/lib/actions/themeActions.ts @@ -23,6 +23,8 @@ import { setAdjustedExtendedColors, getExtractionMode, markApplied, + getIconTheme, + setIconTheme, } from '$lib/stores/theme.svelte'; import {getSettings} from '$lib/stores/settings.svelte'; import type {Settings} from '$lib/types/theme'; @@ -60,6 +62,7 @@ async function runApply(): Promise<{success: boolean}> { extendedColors: getExtendedColors(), settings: getSettings(), appOverrides: getAppOverrides(), + iconTheme: getIconTheme(), } as unknown as main.ApplyThemeRequest); if (result.success) { if (getLightMode()) { @@ -156,6 +159,7 @@ export async function saveAndApplyTheme( extendedColors: getExtendedColors(), settings: getSettings(), appOverrides: getAppOverrides(), + iconTheme: getIconTheme(), } as unknown as main.SaveAndApplyThemeRequest); saveThemeFolder(name); if (result.success) { @@ -250,19 +254,31 @@ export async function applyThemeLive(): Promise { export function undoAction(): void { const snapshot = historyUndo(); if (!snapshot) return; - pushRedo(getPalette(), getExtendedColors(), getAdjustments()); + pushRedo( + getPalette(), + getExtendedColors(), + getAdjustments(), + getIconTheme() + ); setPalette(snapshot.palette, true); setAdjustedExtendedColors(snapshot.extendedColors); setAdjustments(snapshot.adjustments); + setIconTheme(snapshot.iconTheme, true); } export function redoAction(): void { const snapshot = historyRedo(); if (!snapshot) return; - pushUndo(getPalette(), getExtendedColors(), getAdjustments()); + pushUndo( + getPalette(), + getExtendedColors(), + getAdjustments(), + getIconTheme() + ); setPalette(snapshot.palette, true); setAdjustedExtendedColors(snapshot.extendedColors); setAdjustments(snapshot.adjustments); + setIconTheme(snapshot.iconTheme, true); } export async function changeWallpaper(): Promise { diff --git a/frontend/src/lib/components/blueprints/BlueprintsView.svelte b/frontend/src/lib/components/blueprints/BlueprintsView.svelte index c85efa1b..52cd533e 100644 --- a/frontend/src/lib/components/blueprints/BlueprintsView.svelte +++ b/frontend/src/lib/components/blueprints/BlueprintsView.svelte @@ -11,6 +11,7 @@ setAppOverrides, setAdditionalImages, setLastExtractedPath, + setIconTheme, } from '$lib/stores/theme.svelte'; import {DEFAULT_ADJUSTMENTS, type Blueprint} from '$lib/types/theme'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; @@ -74,6 +75,7 @@ setAdjustments({...DEFAULT_ADJUSTMENTS, ...(bp.adjustments ?? {})}); setAppOverrides(bp.appOverrides ?? {}); setAdditionalImages(bp.palette?.additionalImages ?? []); + setIconTheme(bp.iconTheme, true); // Anchor the extract baseline to the blueprint's wallpaper so a // re-extract on it preserves the loaded overrides. if (bp.palette?.wallpaper) { diff --git a/frontend/src/lib/components/blueprints/SaveDialog.svelte b/frontend/src/lib/components/blueprints/SaveDialog.svelte index 07e56dc7..75f234dc 100644 --- a/frontend/src/lib/components/blueprints/SaveDialog.svelte +++ b/frontend/src/lib/components/blueprints/SaveDialog.svelte @@ -8,8 +8,10 @@ getExtendedColors, getAppOverrides, getAdjustments, + getIconTheme, } from '$lib/stores/theme.svelte'; import Modal from '$lib/components/shared/Modal.svelte'; + import type {main} from '../../../../wailsjs/go/models'; let { open = true, @@ -83,7 +85,8 @@ extendedColors: getExtendedColors(), appOverrides: getAppOverrides(), adjustments: {...getAdjustments()}, - }); + iconTheme: getIconTheme(), + } as unknown as main.SaveBlueprintRequest); showToast(`Saved: ${name.trim()}`); onsave(); } catch { diff --git a/frontend/src/lib/components/layout/ActionBar.svelte b/frontend/src/lib/components/layout/ActionBar.svelte index 4b9096ca..81b0227f 100644 --- a/frontend/src/lib/components/layout/ActionBar.svelte +++ b/frontend/src/lib/components/layout/ActionBar.svelte @@ -16,6 +16,8 @@ setAdjustedExtendedColors, isDirty, reset as resetTheme, + getIconTheme, + setIconTheme, } from '$lib/stores/theme.svelte'; import { getCanUndo, @@ -46,6 +48,7 @@ import ConfirmDialog from '$lib/components/shared/ConfirmDialog.svelte'; import KbdInverse from '$lib/components/shared/KbdInverse.svelte'; import Modal from '$lib/components/shared/Modal.svelte'; + import type {main} from '../../../../wailsjs/go/models'; let showImportMenu = $state(false); let showApplyMenu = $state(false); @@ -157,20 +160,32 @@ function handleUndo() { const snapshot = undo(); if (snapshot) { - pushRedo(getPalette(), getExtendedColors(), getAdjustments()); + pushRedo( + getPalette(), + getExtendedColors(), + getAdjustments(), + getIconTheme() + ); setPalette(snapshot.palette, true); setAdjustedExtendedColors(snapshot.extendedColors); setAdjustments(snapshot.adjustments); + setIconTheme(snapshot.iconTheme, true); } } function handleRedo() { const snapshot = redo(); if (snapshot) { - pushUndo(getPalette(), getExtendedColors(), getAdjustments()); + pushUndo( + getPalette(), + getExtendedColors(), + getAdjustments(), + getIconTheme() + ); setPalette(snapshot.palette, true); setAdjustedExtendedColors(snapshot.extendedColors); setAdjustments(snapshot.adjustments); + setIconTheme(snapshot.iconTheme, true); } } @@ -219,7 +234,8 @@ extendedColors: getExtendedColors(), installToOmarchy, appOverrides: getAppOverrides(), - }); + iconTheme: getIconTheme(), + } as unknown as main.ExportThemeRequest); // Path ends with .../omarchy-{slug}-theme — pull the slug so the // user can see what name actually went into Omarchy's menu. const slug = @@ -248,6 +264,7 @@ if (result?.colors?.length >= 16) { setPalette(result.colors); setExtendedColors(result.extendedColors ?? {}); + setIconTheme(result.iconTheme, true); if (result.wallpaperPath) { setWallpaperPath(result.wallpaperPath); } diff --git a/frontend/src/lib/components/sidebar/ColorAdjustments.svelte b/frontend/src/lib/components/sidebar/ColorAdjustments.svelte index dcd024c0..6760be67 100644 --- a/frontend/src/lib/components/sidebar/ColorAdjustments.svelte +++ b/frontend/src/lib/components/sidebar/ColorAdjustments.svelte @@ -20,10 +20,15 @@ setAdjustedExtendedColors, getPaletteCurvePoints, setPaletteCurvePoints, + getIconTheme, } from '$lib/stores/theme.svelte'; import {pushState} from '$lib/stores/history.svelte'; import {ADJUSTMENT_LIMITS} from '$lib/constants/colors'; - import {DEFAULT_ADJUSTMENTS, type Adjustments} from '$lib/types/theme'; + import { + DEFAULT_ADJUSTMENTS, + type Adjustments, + type IconThemeSelection, + } from '$lib/types/theme'; import {debounce} from '$lib/utils/debounce'; import {buildCurveLUT, applyCurveToColors} from '$lib/utils/canvas-filters'; import {hexToRgb} from '$lib/utils/color'; @@ -147,6 +152,7 @@ palette: string[]; ext: Record; adj: Adjustments; + iconTheme: IconThemeSelection; } | null = null; function handleSliderInput(key: string, value: number) { @@ -155,6 +161,7 @@ palette: [...getPalette()], ext: {...getExtendedColors()}, adj: {...getAdjustments()}, + iconTheme: {...getIconTheme()}, }; } const newAdj = {...getAdjustments(), [key]: value}; @@ -167,14 +174,20 @@ pushState( preDragSnapshot.palette, preDragSnapshot.ext, - preDragSnapshot.adj + preDragSnapshot.adj, + preDragSnapshot.iconTheme ); preDragSnapshot = null; } } function resetAll() { - pushState(getPalette(), getExtendedColors(), getAdjustments()); + pushState( + getPalette(), + getExtendedColors(), + getAdjustments(), + getIconTheme() + ); setAdjustments({...DEFAULT_ADJUSTMENTS}); curvePoints = []; setPaletteCurvePoints([]); @@ -190,6 +203,7 @@ palette: string[]; ext: Record; adj: Adjustments; + iconTheme: IconThemeSelection; } | null = null; let nudgeCommitTimer: ReturnType | null = null; @@ -207,6 +221,7 @@ palette: [...getPalette()], ext: {...getExtendedColors()}, adj: {...current}, + iconTheme: {...getIconTheme()}, }; } if (nudgeCommitTimer) clearTimeout(nudgeCommitTimer); @@ -215,7 +230,8 @@ pushState( nudgeSnapshot.palette, nudgeSnapshot.ext, - nudgeSnapshot.adj + nudgeSnapshot.adj, + nudgeSnapshot.iconTheme ); nudgeSnapshot = null; } diff --git a/frontend/src/lib/components/sidebar/IconThemePicker.svelte b/frontend/src/lib/components/sidebar/IconThemePicker.svelte new file mode 100644 index 00000000..3104bed2 --- /dev/null +++ b/frontend/src/lib/components/sidebar/IconThemePicker.svelte @@ -0,0 +1,269 @@ + + +
+ Icons + + +
+ + (open = false)} + panelClass="w-[560px] max-h-[82vh] flex flex-col" +> +
+

+ Choose icon theme +

+ +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + +
+ + + {#if missing && selection.mode === 'explicit'} + + {/if} + + {#if loading && !loaded} +

+ Loading installed icon themes… +

+ {:else if loaded && themes.length === 0} +

+ No installed icon themes were found. Automatic Yaru is still available. +

+ {:else if filteredThemes.length === 0} +

+ No installed themes match this search. +

+ {:else} + {#each filteredThemes as theme (theme.id + ':' + catalogRevision)} + + {/each} + {/if} +
+
diff --git a/frontend/src/lib/components/sidebar/IconThemePreview.svelte b/frontend/src/lib/components/sidebar/IconThemePreview.svelte new file mode 100644 index 00000000..bc5ef137 --- /dev/null +++ b/frontend/src/lib/components/sidebar/IconThemePreview.svelte @@ -0,0 +1,88 @@ + + + + {#each concepts as concept} + {@const sample = byKind.get(concept.kind)} + + {#if sample} + {`${concept.label} + {:else} + + {/if} + + {/each} + {#if loaded && samples.length === 0} + Preview unavailable + {/if} + diff --git a/frontend/src/lib/components/sidebar/SettingsSidebar.svelte b/frontend/src/lib/components/sidebar/SettingsSidebar.svelte index ee28f7b4..0a3d325b 100644 --- a/frontend/src/lib/components/sidebar/SettingsSidebar.svelte +++ b/frontend/src/lib/components/sidebar/SettingsSidebar.svelte @@ -7,6 +7,7 @@ import AccessibilityPanel from './AccessibilityPanel.svelte'; import NeovimThemes from './NeovimThemes.svelte'; import TemplateToggles from './TemplateToggles.svelte'; + import IconThemePicker from './IconThemePicker.svelte'; import SectionLabel from '$lib/components/shared/SectionLabel.svelte'; import {getLightMode, setLightMode} from '$lib/stores/theme.svelte'; @@ -33,6 +34,10 @@ > + +
+ +
diff --git a/frontend/src/lib/components/sidebar/TemplateToggles.svelte b/frontend/src/lib/components/sidebar/TemplateToggles.svelte index 9ae0570e..3c49c83d 100644 --- a/frontend/src/lib/components/sidebar/TemplateToggles.svelte +++ b/frontend/src/lib/components/sidebar/TemplateToggles.svelte @@ -31,7 +31,9 @@ appList = Object.keys(result || {}) .filter( k => - !SPECIAL_APP_KEYS.has(k) && !ALWAYS_INCLUDED_APPS.has(k) + !SPECIAL_APP_KEYS.has(k) && + !ALWAYS_INCLUDED_APPS.has(k) && + k !== 'icons' ) .sort(); } catch { diff --git a/frontend/src/lib/stores/history.svelte.ts b/frontend/src/lib/stores/history.svelte.ts index be2aa2a6..d47a7769 100644 --- a/frontend/src/lib/stores/history.svelte.ts +++ b/frontend/src/lib/stores/history.svelte.ts @@ -1,6 +1,6 @@ // Frontend-side undo/redo history -import type {Adjustments} from '$lib/types/theme'; +import type {Adjustments, IconThemeSelection} from '$lib/types/theme'; const MAX_HISTORY = 50; @@ -8,6 +8,7 @@ interface Snapshot { palette: string[]; extendedColors: Record; adjustments: Adjustments; + iconTheme: IconThemeSelection; } let undoStack = $state([]); @@ -28,18 +29,29 @@ export function getCanRedo(): boolean { return canRedo; } +function snapshotOf( + palette: string[], + extendedColors: Record, + adjustments: Adjustments, + iconTheme: IconThemeSelection +): Snapshot { + return { + palette: [...palette], + extendedColors: {...extendedColors}, + adjustments: {...adjustments}, + iconTheme: {...iconTheme}, + }; +} + export function pushState( palette: string[], extendedColors: Record, - adjustments: Adjustments + adjustments: Adjustments, + iconTheme: IconThemeSelection ): void { undoStack = [ ...undoStack.slice(-(MAX_HISTORY - 1)), - { - palette: [...palette], - extendedColors: {...extendedColors}, - adjustments: {...adjustments}, - }, + snapshotOf(palette, extendedColors, adjustments, iconTheme), ]; redoStack = []; updateFlags(); @@ -49,15 +61,12 @@ export function pushState( export function pushUndo( palette: string[], extendedColors: Record, - adjustments: Adjustments + adjustments: Adjustments, + iconTheme: IconThemeSelection ): void { undoStack = [ ...undoStack.slice(-(MAX_HISTORY - 1)), - { - palette: [...palette], - extendedColors: {...extendedColors}, - adjustments: {...adjustments}, - }, + snapshotOf(palette, extendedColors, adjustments, iconTheme), ]; updateFlags(); } @@ -73,15 +82,12 @@ export function undo(): Snapshot | null { export function pushRedo( palette: string[], extendedColors: Record, - adjustments: Adjustments + adjustments: Adjustments, + iconTheme: IconThemeSelection ): void { redoStack = [ ...redoStack, - { - palette: [...palette], - extendedColors: {...extendedColors}, - adjustments: {...adjustments}, - }, + snapshotOf(palette, extendedColors, adjustments, iconTheme), ]; updateFlags(); } diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index 68f1aa6e..fbe492bf 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -3,6 +3,9 @@ import { DEFAULT_ADJUSTMENTS, type Adjustments, type ColorRoles, + type IconThemeSelection, + AUTOMATIC_ICON_THEME, + normalizeIconThemeSelection, } from '$lib/types/theme'; import {pushState} from '$lib/stores/history.svelte'; @@ -20,6 +23,7 @@ let isExtracting = $state(false); let isApplying = $state(false); let additionalImages = $state([]); let appOverrides = $state>>({}); +let iconTheme = $state({...AUTOMATIC_ICON_THEME}); let paletteCurvePoints = $state<[number, number][]>([]); // Source path of the most recently extracted palette. Used to decide // whether per-app template overrides should be cleared on the next @@ -136,6 +140,9 @@ export function getBaseExtendedColors(): Record { export function getAppOverrides(): Record> { return appOverrides; } +export function getIconTheme(): IconThemeSelection { + return iconTheme; +} // Snapshot of the fields mirrored into Go for IPC reads (aether status) and // for constructing ApplyThemeRequest/SaveBlueprintRequest payloads. @@ -146,6 +153,7 @@ export function getThemeSnapshot(): { extendedColors: Record; appOverrides: Record>; additionalImages: string[]; + iconTheme: IconThemeSelection; } { return { palette, @@ -154,6 +162,7 @@ export function getThemeSnapshot(): { extendedColors, appOverrides, additionalImages, + iconTheme, }; } @@ -167,6 +176,7 @@ export function getThemeSignature(): string { extendedColors, appOverrides, additionalImages, + iconTheme, ]); } @@ -219,7 +229,7 @@ let hasEverChanged = false; export function setPalette(colors: string[], skipHistory = false): void { if (!skipHistory && hasEverChanged) { - pushState(palette, extendedColors, adjustments); + pushState(palette, extendedColors, adjustments, iconTheme); } hasEverChanged = true; basePalette = [...colors]; @@ -294,7 +304,7 @@ let colorEditSnapshotPushed = false; export function setColor(index: number, hex: string): void { // Push history once at the start of a color edit session, not on every drag tick if (!colorEditSnapshotPushed) { - pushState(palette, extendedColors, adjustments); + pushState(palette, extendedColors, adjustments, iconTheme); colorEditSnapshotPushed = true; } if (colorEditTimer) clearTimeout(colorEditTimer); @@ -313,7 +323,7 @@ let extEditTimer: ReturnType | null = null; export function setExtendedColor(key: string, hex: string): void { if (!extEditSnapshotPushed) { - pushState(palette, extendedColors, adjustments); + pushState(palette, extendedColors, adjustments, iconTheme); extEditSnapshotPushed = true; } if (extEditTimer) clearTimeout(extEditTimer); @@ -334,7 +344,7 @@ export function clearExtendedColors(keys: string[]): void { k => k in extendedColors || k in baseExtendedColors ); if (present.length === 0) return; - pushState(palette, extendedColors, adjustments); + pushState(palette, extendedColors, adjustments, iconTheme); const next = {...extendedColors}; const base = {...baseExtendedColors}; for (const k of present) { @@ -375,6 +385,18 @@ export function setAdditionalImages(images: string[]): void { additionalImages = [...images]; } +export function setIconTheme( + selection: {mode?: string; id?: string} | null | undefined, + skipHistory = false +): void { + const next = normalizeIconThemeSelection(selection); + if (next.mode === iconTheme.mode && next.id === iconTheme.id) return; + if (!skipHistory) { + pushState(palette, extendedColors, adjustments, iconTheme); + } + iconTheme = {...next}; +} + export function addAdditionalImage(path: string): void { if (!additionalImages.includes(path)) { additionalImages = [...additionalImages, path]; @@ -403,7 +425,7 @@ export function swapMainWithAdditional(path: string): void { // Randomly reassigns ANSI color roles 1-6 (and their bright counterparts 9-14). // Locked colors are excluded from the shuffle. export function shufflePalette(): void { - pushState(palette, extendedColors, adjustments); + pushState(palette, extendedColors, adjustments, iconTheme); const indices = [1, 2, 3, 4, 5, 6]; const unlocked = indices.filter(i => !lockedColors[i]); @@ -446,5 +468,6 @@ export function reset(): void { extendedColors = {...ext}; baseExtendedColors = {...ext}; appOverrides = {}; + iconTheme = {...AUTOMATIC_ICON_THEME}; paletteCurvePoints = []; } diff --git a/frontend/src/lib/types/theme.ts b/frontend/src/lib/types/theme.ts index 1268d8bb..99a6d411 100644 --- a/frontend/src/lib/types/theme.ts +++ b/frontend/src/lib/types/theme.ts @@ -38,6 +38,20 @@ export interface Adjustments { whitePoint: number; } +export type IconThemeSelection = + {mode: 'automatic'; id?: never} | {mode: 'explicit'; id: string}; + +export const AUTOMATIC_ICON_THEME: IconThemeSelection = {mode: 'automatic'}; + +export function normalizeIconThemeSelection( + value: {mode?: string; id?: string} | null | undefined +): IconThemeSelection { + if (value?.mode === 'explicit' && value.id) { + return {mode: 'explicit', id: value.id}; + } + return {...AUTOMATIC_ICON_THEME}; +} + // Blueprint shape returned by ListBlueprints (Go side returns untyped maps, // so this mirrors internal/blueprint.Blueprint by hand). export interface BlueprintPaletteData { @@ -57,6 +71,7 @@ export interface Blueprint { adjustments?: Record; appOverrides?: Record>; settings?: Record; + iconTheme?: IconThemeSelection; timestamp: number; path?: string; filename?: string; diff --git a/frontend/tests/icon-theme.test.mjs b/frontend/tests/icon-theme.test.mjs new file mode 100644 index 00000000..6b38ca3e --- /dev/null +++ b/frontend/tests/icon-theme.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import test from 'node:test'; + +import { + AUTOMATIC_ICON_THEME, + normalizeIconThemeSelection, +} from '../src/lib/types/theme.ts'; + +test('legacy and automatic selections normalize to Automatic', () => { + assert.deepEqual( + normalizeIconThemeSelection(undefined), + AUTOMATIC_ICON_THEME + ); + assert.deepEqual( + normalizeIconThemeSelection({mode: 'automatic'}), + AUTOMATIC_ICON_THEME + ); +}); + +test('safe missing explicit selection is preserved exactly', () => { + assert.deepEqual( + normalizeIconThemeSelection({ + mode: 'explicit', + id: 'Missing-But-Safe', + }), + {mode: 'explicit', id: 'Missing-But-Safe'} + ); +}); + +test('icon theme control is promoted below Light mode, not buried in Apps', async () => { + const settingsSidebar = await readFile( + new URL( + '../src/lib/components/sidebar/SettingsSidebar.svelte', + import.meta.url + ), + 'utf8' + ); + const templateToggles = await readFile( + new URL( + '../src/lib/components/sidebar/TemplateToggles.svelte', + import.meta.url + ), + 'utf8' + ); + const picker = settingsSidebar.indexOf('= 0, 'SettingsSidebar must render IconThemePicker'); + assert.ok(picker < generate, 'IconThemePicker must appear above Generate'); + assert.ok( + !templateToggles.includes(' { + const picker = await readFile( + new URL( + '../src/lib/components/sidebar/IconThemePicker.svelte', + import.meta.url + ), + 'utf8' + ); + assert.ok( + picker.includes('catalogRevision'), + 'IconThemePicker must version preview rows after a catalog refresh' + ); + assert.match( + picker, + /#each filteredThemes as theme \(theme\.id \+ ['"]:['"] \+ catalogRevision\)/, + 'preview row keys must include the catalog revision' + ); +}); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index cda7eec6..78b55c99 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -4,6 +4,7 @@ import {color} from '../models'; import {theme} from '../models'; import {main} from '../models'; import {favorites} from '../models'; +import {icontheme} from '../models'; import {ipc} from '../models'; import {omarchy} from '../models'; import {wallpaper} from '../models'; @@ -70,14 +71,18 @@ export function GetFavorites(): Promise>; export function GetFocusTab(): Promise; -export function GetInitialState(): Promise; +export function GetIconThemePreview( + arg1: string +): Promise; -export function GetReleaseStatus(arg1: string): Promise>; +export function GetInitialState(): Promise; export function GetPendingExternalImport(): Promise; export function GetPreview(arg1: string): Promise; +export function GetReleaseStatus(arg1: string): Promise>; + export function GetSettings(): Promise>; export function GetTemplateColors(): Promise>>; @@ -106,6 +111,10 @@ export function IsPreviewCached(arg1: string): Promise; export function ListBlueprints(): Promise>>; +export function ListInstalledIconThemes(): Promise< + Array +>; + export function LoadBlueprint(arg1: string): Promise; export function LoadOmarchyThemes(): Promise>; @@ -122,9 +131,15 @@ export function PreviewExtractColors( export function ReadImageAsDataURL(arg1: string): Promise; +export function RefreshInstalledIconThemes(): Promise< + Array +>; + export function ResetState(): Promise; -export function SaveAndApplyTheme(arg1: main.SaveAndApplyThemeRequest): Promise; +export function SaveAndApplyTheme( + arg1: main.SaveAndApplyThemeRequest +): Promise; export function SaveBlueprint(arg1: main.SaveBlueprintRequest): Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 6fa53ec2..d9aaa313 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -94,12 +94,12 @@ export function GetFocusTab() { return window['go']['main']['App']['GetFocusTab'](); } -export function GetInitialState() { - return window['go']['main']['App']['GetInitialState'](); +export function GetIconThemePreview(arg1) { + return window['go']['main']['App']['GetIconThemePreview'](arg1); } -export function GetReleaseStatus(arg1) { - return window['go']['main']['App']['GetReleaseStatus'](arg1); +export function GetInitialState() { + return window['go']['main']['App']['GetInitialState'](); } export function GetPendingExternalImport() { @@ -110,6 +110,10 @@ export function GetPreview(arg1) { return window['go']['main']['App']['GetPreview'](arg1); } +export function GetReleaseStatus(arg1) { + return window['go']['main']['App']['GetReleaseStatus'](arg1); +} + export function GetSettings() { return window['go']['main']['App']['GetSettings'](); } @@ -166,6 +170,10 @@ export function ListBlueprints() { return window['go']['main']['App']['ListBlueprints'](); } +export function ListInstalledIconThemes() { + return window['go']['main']['App']['ListInstalledIconThemes'](); +} + export function LoadBlueprint(arg1) { return window['go']['main']['App']['LoadBlueprint'](arg1); } @@ -194,6 +202,10 @@ export function ReadImageAsDataURL(arg1) { return window['go']['main']['App']['ReadImageAsDataURL'](arg1); } +export function RefreshInstalledIconThemes() { + return window['go']['main']['App']['RefreshInstalledIconThemes'](); +} + export function ResetState() { return window['go']['main']['App']['ResetState'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 14f24f79..c829b30e 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -54,6 +54,91 @@ export namespace favorites { } } +export namespace icontheme { + export class PreviewSample { + kind: string; + pngData: string; + + static createFrom(source: any = {}) { + return new PreviewSample(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.kind = source['kind']; + this.pngData = source['pngData']; + } + } + export class Selection { + mode: string; + id?: string; + + static createFrom(source: any = {}) { + return new Selection(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mode = source['mode']; + this.id = source['id']; + } + } + export class ThemePreview { + themeId: string; + samples: PreviewSample[]; + + static createFrom(source: any = {}) { + return new ThemePreview(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.themeId = source['themeId']; + this.samples = this.convertValues(source['samples'], PreviewSample); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => + this.convertValues(elem, classs) + ); + } else if ('object' === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class ThemeSummary { + id: string; + name: string; + inherits?: string[]; + origin: string; + hasPreview: boolean; + + static createFrom(source: any = {}) { + return new ThemeSummary(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source['id']; + this.name = source['name']; + this.inherits = source['inherits']; + this.origin = source['origin']; + this.hasPreview = source['hasPreview']; + } + } +} + export namespace ipc { export class Request { cmd: string; @@ -140,6 +225,7 @@ export namespace main { extendedColors: Record; settings: theme.Settings; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ApplyThemeRequest(source); @@ -157,6 +243,10 @@ export namespace main { theme.Settings ); this.appOverrides = source['appOverrides']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -189,6 +279,7 @@ export namespace main { extendedColors: Record; installToOmarchy: boolean; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ExportThemeRequest(source); @@ -205,6 +296,30 @@ export namespace main { this.extendedColors = source['extendedColors']; this.installToOmarchy = source['installToOmarchy']; this.appOverrides = source['appOverrides']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => + this.convertValues(elem, classs) + ); + } else if ('object' === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; } } export class ExternalImportPreview { @@ -258,6 +373,7 @@ export namespace main { path: string; wallpaperPath: string; lightMode: boolean; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ImportResult(source); @@ -271,6 +387,30 @@ export namespace main { this.path = source['path']; this.wallpaperPath = source['wallpaperPath']; this.lightMode = source['lightMode']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => + this.convertValues(elem, classs) + ); + } else if ('object' === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; } } export class SaveAndApplyThemeRequest { @@ -283,6 +423,7 @@ export namespace main { extendedColors: Record; settings: theme.Settings; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SaveAndApplyThemeRequest(source); @@ -302,6 +443,10 @@ export namespace main { theme.Settings ); this.appOverrides = source['appOverrides']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -334,6 +479,7 @@ export namespace main { extendedColors: Record; appOverrides: Record; adjustments: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SaveBlueprintRequest(source); @@ -350,6 +496,30 @@ export namespace main { this.extendedColors = source['extendedColors']; this.appOverrides = source['appOverrides']; this.adjustments = source['adjustments']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => + this.convertValues(elem, classs) + ); + } else if ('object' === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; } } export class SyncStateRequest { @@ -359,6 +529,7 @@ export namespace main { extendedColors: Record; appOverrides: Record; additionalImages: string[]; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SyncStateRequest(source); @@ -372,6 +543,30 @@ export namespace main { this.extendedColors = source['extendedColors']; this.appOverrides = source['appOverrides']; this.additionalImages = source['additionalImages']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => + this.convertValues(elem, classs) + ); + } else if ('object' === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; } } } @@ -515,6 +710,7 @@ export namespace theme { extractionMode: string; additionalImages: string[]; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new StateSnapshot(source); @@ -534,6 +730,10 @@ export namespace theme { this.extractionMode = source['extractionMode']; this.additionalImages = source['additionalImages']; this.appOverrides = source['appOverrides']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/blueprint/icon_theme_test.go b/internal/blueprint/icon_theme_test.go new file mode 100644 index 00000000..8a25081e --- /dev/null +++ b/internal/blueprint/icon_theme_test.go @@ -0,0 +1,111 @@ +package blueprint + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestImportJSONIconThemeCompatibility(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + iconThemeJSON string + wantField bool + wantMode string + wantID string + wantErr string + }{ + {name: "legacy absent is automatic"}, + { + name: "automatic canonical encoding is omitted", + iconThemeJSON: `,"iconTheme":{"mode":"automatic"}`, + }, + { + name: "explicit safe missing ID round trips", + iconThemeJSON: `,"iconTheme":{"mode":"explicit","id":"Missing-But-Safe"}`, + wantField: true, + wantMode: "explicit", + wantID: "Missing-But-Safe", + }, + { + name: "unsafe explicit ID is rejected", + iconThemeJSON: `,"iconTheme":{"mode":"explicit","id":"../escape"}`, + wantErr: "iconTheme", + }, + { + name: "unknown mode is rejected", + iconThemeJSON: `,"iconTheme":{"mode":"installed","id":"Papirus"}`, + wantErr: "iconTheme", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "theme.json") + data := fmt.Sprintf( + `{"name":"Portable","palette":{"colors":%s}%s}`, + validPaletteJSON(), + tt.iconThemeJSON, + ) + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + + bp, err := ImportJSON(path) + if tt.wantErr != "" { + if err == nil { + t.Fatal("ImportJSON accepted an invalid iconTheme") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("ImportJSON error = %q, want field context %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ImportJSON: %v", err) + } + + encoded, err := json.Marshal(bp) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(encoded, &raw); err != nil { + t.Fatal(err) + } + field, found := raw["iconTheme"] + if found != tt.wantField { + t.Fatalf("marshaled iconTheme present = %v, want %v; JSON: %s", found, tt.wantField, encoded) + } + if !found { + return + } + var selection struct { + Mode string `json:"mode"` + ID string `json:"id"` + } + if err := json.Unmarshal(field, &selection); err != nil { + t.Fatal(err) + } + if selection.Mode != tt.wantMode || selection.ID != tt.wantID { + t.Errorf("iconTheme = %+v, want mode=%q id=%q", selection, tt.wantMode, tt.wantID) + } + }) + } +} + +func validPaletteJSON() string { + colors := make([]string, 16) + for i := range colors { + colors[i] = fmt.Sprintf("#%06x", i) + } + data, _ := json.Marshal(colors) + return string(data) +} diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index c77c9d1f..58972d8b 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -1,6 +1,10 @@ package blueprint -import "encoding/json" +import ( + "encoding/json" + + "aether/internal/icontheme" +) // Blueprint represents a saved theme configuration. type Blueprint struct { @@ -9,12 +13,37 @@ type Blueprint struct { Adjustments map[string]float64 `json:"adjustments,omitempty"` AppOverrides map[string]map[string]string `json:"appOverrides,omitempty"` Settings Settings `json:"settings,omitempty"` + IconTheme *icontheme.Selection `json:"iconTheme,omitempty"` Timestamp int64 `json:"timestamp"` // Metadata (not persisted in the JSON, populated on load) Path string `json:"-"` Filename string `json:"-"` } +// IconThemeSelection returns the normalized blueprint choice. A missing field +// is the backward-compatible Automatic selection. +func (b *Blueprint) IconThemeSelection() (icontheme.Selection, error) { + if b == nil || b.IconTheme == nil { + return icontheme.Automatic(), nil + } + return icontheme.NormalizeSelection(*b.IconTheme) +} + +// SetIconThemeSelection validates and stores the canonical blueprint encoding: +// Automatic is omitted, while Explicit is written as theme content. +func (b *Blueprint) SetIconThemeSelection(selection icontheme.Selection) error { + normalized, err := icontheme.NormalizeSelection(selection) + if err != nil { + return err + } + if normalized.Mode == icontheme.SelectionAutomatic { + b.IconTheme = nil + return nil + } + b.IconTheme = &normalized + return nil +} + // UnmarshalJSON removes state for integrations no longer supported by Aether. func (b *Blueprint) UnmarshalJSON(data []byte) error { type Alias Blueprint diff --git a/internal/blueprint/validate.go b/internal/blueprint/validate.go index 082ff4d2..41706252 100644 --- a/internal/blueprint/validate.go +++ b/internal/blueprint/validate.go @@ -35,5 +35,12 @@ func validateBlueprint(bp *Blueprint) error { return fmt.Errorf("locked color index %d is out of range", index) } } + selection, err := bp.IconThemeSelection() + if err != nil { + return fmt.Errorf("iconTheme: %w", err) + } + if err := bp.SetIconThemeSelection(selection); err != nil { + return fmt.Errorf("iconTheme: %w", err) + } return nil } diff --git a/internal/icontheme/catalog.go b/internal/icontheme/catalog.go new file mode 100644 index 00000000..b74fa5cf --- /dev/null +++ b/internal/icontheme/catalog.go @@ -0,0 +1,402 @@ +package icontheme + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +const ( + MaxRoots = 64 + MaxEntriesPerRoot = 4096 + MaxThemes = 1024 + MaxMetadataBytes int64 = 1 << 20 + MaxMetadataLineBytes = 64 << 10 + PreviewSize = 96 +) + +// Origin identifies whether effective theme metadata came from a user or +// system icon root. +type Origin string + +const ( + OriginUser Origin = "user" + OriginSystem Origin = "system" +) + +// Root is an approved icon-theme search root. +type Root struct { + Path string + Origin Origin +} + +// ThemeSummary is the path-free catalog DTO returned to the frontend. +type ThemeSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Inherits []string `json:"inherits,omitempty"` + Origin Origin `json:"origin"` + HasPreview bool `json:"hasPreview"` +} + +// PreviewSample is one backend-rasterized representative icon. +type PreviewSample struct { + Kind string `json:"kind"` + PNGData string `json:"pngData"` +} + +// ThemePreview contains only bounded PNG data, never source paths or markup. +type ThemePreview struct { + ThemeID string `json:"themeId"` + Samples []PreviewSample `json:"samples"` +} + +type canonicalRoot struct { + path string + origin Origin +} + +type themeFragment struct { + path string + origin Origin + metadata *themeMetadata +} + +type themeRecord struct { + id string + fragments []themeFragment + metadata themeMetadata + origin Origin +} + +// BuildRoots applies XDG icon-root precedence to explicit environment values. +func BuildRoots(home, dataHome, dataDirs string) []Root { + if !filepath.IsAbs(dataHome) { + dataHome = filepath.Join(home, ".local", "share") + } + if dataDirs == "" { + dataDirs = "/usr/local/share:/usr/share" + } + + roots := make([]Root, 0, 6) + seen := make(map[string]struct{}) + add := func(path string, origin Origin) { + if len(roots) >= MaxRoots || !filepath.IsAbs(path) { + return + } + path = filepath.Clean(path) + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + roots = append(roots, Root{Path: path, Origin: origin}) + } + + add(filepath.Join(dataHome, "icons"), OriginUser) + if filepath.IsAbs(home) { + add(filepath.Join(home, ".icons"), OriginUser) + } + for _, entry := range filepath.SplitList(dataDirs) { + if entry == "" || !filepath.IsAbs(entry) { + continue + } + add(filepath.Join(entry, "icons"), OriginSystem) + } + add("/usr/local/share/icons", OriginSystem) + add("/usr/share/icons", OriginSystem) + return roots +} + +// Catalog is a cached, read-only installed icon-theme catalog. +type Catalog struct { + roots []Root + rootsProvider func() []Root + + scanMu sync.Mutex + mu sync.RWMutex + loaded bool + items []ThemeSummary + themes map[string]*themeRecord + generation uint64 + + previewMu sync.Mutex + previewCache map[string]ThemePreview + previewOrder []string + previewSem chan struct{} +} + +// NewCatalog uses icon roots from the current launch environment. +func NewCatalog() *Catalog { + provider := func() []Root { + home, _ := os.UserHomeDir() + return BuildRoots(home, os.Getenv("XDG_DATA_HOME"), os.Getenv("XDG_DATA_DIRS")) + } + catalog := NewCatalogWithRoots(provider()) + catalog.rootsProvider = provider + return catalog +} + +// NewCatalogWithRoots constructs a catalog using injected approved roots. +func NewCatalogWithRoots(roots []Root) *Catalog { + copyRoots := append([]Root(nil), roots...) + if len(copyRoots) > MaxRoots { + copyRoots = copyRoots[:MaxRoots] + } + return &Catalog{ + roots: copyRoots, + themes: make(map[string]*themeRecord), + previewCache: make(map[string]ThemePreview), + previewSem: make(chan struct{}, 4), + } +} + +// List returns a cached path-free catalog snapshot. +func (c *Catalog) List(ctx context.Context) ([]ThemeSummary, error) { + c.mu.RLock() + if c.loaded { + items := cloneSummaries(c.items) + c.mu.RUnlock() + return items, nil + } + c.mu.RUnlock() + return c.Refresh(ctx) +} + +// Refresh rescans roots and atomically replaces the catalog snapshot. +func (c *Catalog) Refresh(ctx context.Context) ([]ThemeSummary, error) { + c.scanMu.Lock() + defer c.scanMu.Unlock() + + c.mu.RLock() + roots := append([]Root(nil), c.roots...) + c.mu.RUnlock() + if c.rootsProvider != nil { + roots = c.rootsProvider() + if len(roots) > MaxRoots { + roots = roots[:MaxRoots] + } + } + items, themes, err := scanCatalog(ctx, roots) + if err != nil { + return nil, err + } + c.mu.Lock() + c.items = cloneSummaries(items) + c.themes = themes + c.roots = append([]Root(nil), roots...) + c.loaded = true + c.generation++ + c.mu.Unlock() + c.previewMu.Lock() + c.previewCache = make(map[string]ThemePreview) + c.previewOrder = nil + c.previewMu.Unlock() + return cloneSummaries(items), nil +} + +func scanCatalog(ctx context.Context, roots []Root) ([]ThemeSummary, map[string]*themeRecord, error) { + canonical := canonicalizeRoots(roots) + fragments := make(map[string][]themeFragment) + readableRoots := 0 + for _, root := range canonical { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + names, err := boundedDirectoryNames(root.path) + if err != nil { + continue + } + readableRoots++ + for _, name := range names { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if ValidateID(name) != nil { + continue + } + path, err := resolveContained(filepath.Join(root.path, name), canonical, true) + if err != nil { + continue + } + fragment := themeFragment{path: path, origin: root.origin} + if !containsFragment(fragments[name], fragment.path) { + fragments[name] = append(fragments[name], fragment) + } + } + } + if len(canonical) > 0 && readableRoots == 0 { + return nil, nil, errors.New("no approved icon roots are readable") + } + + ids := make([]string, 0, len(fragments)) + for id := range fragments { + ids = append(ids, id) + } + sort.Strings(ids) + items := make([]ThemeSummary, 0, len(ids)) + themes := make(map[string]*themeRecord, len(ids)) + for _, id := range ids { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + var effective themeMetadata + var origin Origin + found := false + recordFragments := append([]themeFragment(nil), fragments[id]...) + for i := range recordFragments { + fragment := &recordFragments[i] + metadata, err := parseThemeMetadata(filepath.Join(fragment.path, "index.theme"), canonical) + if err != nil { + continue + } + fragment.metadata = &metadata + if !found { + effective = metadata + origin = fragment.origin + found = true + } + } + if !found || effective.hidden { + continue + } + for i := range recordFragments { + if recordFragments[i].metadata == nil { + recordFragments[i].metadata = &effective + } + } + name := effective.name + if name == "" { + name = id + } + hasPreview := len(effective.inherits) > 0 + for _, fragment := range recordFragments { + if fragment.metadata != nil && len(fragment.metadata.directories) > 0 { + hasPreview = true + break + } + } + record := &themeRecord{id: id, fragments: recordFragments, metadata: effective, origin: origin} + themes[id] = record + items = append(items, ThemeSummary{ + ID: id, + Name: name, + Inherits: append([]string(nil), effective.inherits...), + Origin: origin, + HasPreview: hasPreview, + }) + if len(items) >= MaxThemes { + break + } + } + sort.Slice(items, func(i, j int) bool { + left := strings.ToLower(items[i].Name) + right := strings.ToLower(items[j].Name) + if left == right { + return items[i].ID < items[j].ID + } + return left < right + }) + return items, themes, nil +} + +func canonicalizeRoots(roots []Root) []canonicalRoot { + result := make([]canonicalRoot, 0, len(roots)) + seen := make(map[string]struct{}) + for _, root := range roots { + if len(result) >= MaxRoots || !filepath.IsAbs(root.Path) { + continue + } + canonical, err := filepath.EvalSymlinks(filepath.Clean(root.Path)) + if err != nil { + continue + } + info, err := os.Stat(canonical) + if err != nil || !info.IsDir() { + continue + } + canonical = filepath.Clean(canonical) + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + result = append(result, canonicalRoot{path: canonical, origin: root.Origin}) + } + return result +} + +func boundedDirectoryNames(path string) ([]string, error) { + dir, err := os.Open(path) + if err != nil { + return nil, err + } + defer dir.Close() + names, err := dir.Readdirnames(MaxEntriesPerRoot + 1) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if len(names) > MaxEntriesPerRoot { + names = names[:MaxEntriesPerRoot] + } + sort.Strings(names) + return names, nil +} + +func resolveContained(path string, roots []canonicalRoot, wantDir bool) (string, error) { + if _, err := os.Lstat(path); err != nil { + return "", err + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", err + } + resolved = filepath.Clean(resolved) + contained := false + for _, root := range roots { + if pathWithin(root.path, resolved) { + contained = true + break + } + } + if !contained { + return "", errors.New("resolved path escapes approved icon roots") + } + info, err := os.Stat(resolved) + if err != nil { + return "", err + } + if wantDir != info.IsDir() || (!wantDir && !info.Mode().IsRegular()) { + return "", errors.New("resolved path has unexpected file type") + } + return resolved, nil +} + +func pathWithin(root, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)) +} + +func containsFragment(fragments []themeFragment, path string) bool { + for _, fragment := range fragments { + if fragment.path == path { + return true + } + } + return false +} + +func cloneSummaries(items []ThemeSummary) []ThemeSummary { + result := make([]ThemeSummary, len(items)) + for i, item := range items { + result[i] = item + result[i].Inherits = append([]string(nil), item.Inherits...) + } + return result +} diff --git a/internal/icontheme/catalog_test.go b/internal/icontheme/catalog_test.go new file mode 100644 index 00000000..51b1d86e --- /dev/null +++ b/internal/icontheme/catalog_test.go @@ -0,0 +1,333 @@ +package icontheme + +import ( + "bytes" + "context" + "errors" + "fmt" + "image/color" + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestParseINIAcceptsExactLineBoundAndRejectsOneByteOver(t *testing.T) { + t.Parallel() + + prefix := []byte("Name=") + exactLine := append(append([]byte(nil), prefix...), bytes.Repeat([]byte{'x'}, MaxMetadataLineBytes-len(prefix))...) + exact := append([]byte("[Icon Theme]\n"), exactLine...) + exact = append(exact, '\n') + sections, err := parseINI(exact) + if err != nil { + t.Fatalf("parseINI(exact %d-byte line): %v", MaxMetadataLineBytes, err) + } + if len(sections["Icon Theme"]["Name"]) != MaxMetadataLineBytes-len(prefix) { + t.Fatal("parseINI truncated exact-boundary metadata") + } + + over := append(append([]byte(nil), exactLine...), 'x') + overData := append([]byte("[Icon Theme]\n"), over...) + overData = append(overData, '\n') + if _, err := parseINI(overData); err == nil { + t.Fatalf("parseINI(%d-byte line) error = nil, want bound rejection", MaxMetadataLineBytes+1) + } +} + +func TestCatalogListCachesAndRefreshReplacesSnapshot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeThemeIndex(t, root, "One", "[Icon Theme]\nName=One\n") + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + first, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + writeThemeIndex(t, root, "Two", "[Icon Theme]\nName=Two\n") + cached, err := catalog.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cached, first) { + t.Errorf("cached List() = %#v, want unchanged %#v", cached, first) + } + refreshed, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(refreshed) != 2 { + t.Errorf("Refresh() returned %d themes, want 2", len(refreshed)) + } +} + +func TestCatalogRefreshHonorsCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + catalog := NewCatalogWithRoots([]Root{{Path: t.TempDir(), Origin: OriginUser}}) + if _, err := catalog.Refresh(ctx); !errors.Is(err, context.Canceled) { + t.Errorf("Refresh(canceled) error = %v, want context.Canceled", err) + } +} + +func TestCatalogRefreshErrorsOnlyWhenEveryApprovedRootIsUnreadable(t *testing.T) { + readable := t.TempDir() + unreadable := t.TempDir() + writeThemeIndex(t, readable, "Readable", "[Icon Theme]\nName=Readable\n") + if err := os.Chmod(unreadable, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(unreadable, 0o700) }) + + partial := NewCatalogWithRoots([]Root{ + {Path: unreadable, Origin: OriginUser}, + {Path: readable, Origin: OriginSystem}, + }) + items, err := partial.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh(partial roots) error = %v, want usable partial result", err) + } + if len(items) != 1 || items[0].ID != "Readable" { + t.Fatalf("Refresh(partial roots) = %#v, want Readable", items) + } + + unreliable := NewCatalogWithRoots([]Root{{Path: unreadable, Origin: OriginUser}}) + if _, err := unreliable.Refresh(context.Background()); err == nil { + t.Fatal("Refresh(all unreadable roots) error = nil, want catalog error") + } +} + +func TestBuildRootsUsesXDGPrecedenceAndDeduplicates(t *testing.T) { + t.Parallel() + + got := BuildRoots( + "/home/tester", + "", + "/opt/share::relative:/usr/share:/opt/share", + ) + want := []Root{ + {Path: "/home/tester/.local/share/icons", Origin: OriginUser}, + {Path: "/home/tester/.icons", Origin: OriginUser}, + {Path: "/opt/share/icons", Origin: OriginSystem}, + {Path: "/usr/share/icons", Origin: OriginSystem}, + {Path: "/usr/local/share/icons", Origin: OriginSystem}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildRoots() = %#v, want %#v", got, want) + } +} + +func TestBuildRootsUsesExplicitDataHomeAndDefaultDataDirs(t *testing.T) { + t.Parallel() + + got := BuildRoots("/home/tester", "/data/user", "") + want := []Root{ + {Path: "/data/user/icons", Origin: OriginUser}, + {Path: "/home/tester/.icons", Origin: OriginUser}, + {Path: "/usr/local/share/icons", Origin: OriginSystem}, + {Path: "/usr/share/icons", Origin: OriginSystem}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("BuildRoots() = %#v, want %#v", got, want) + } +} + +func TestCatalogDiscoveryMetadataPrecedenceAndSort(t *testing.T) { + t.Parallel() + + user := t.TempDir() + system := t.TempDir() + writeThemeIndex(t, user, "Papirus", `[Icon Theme] +Name=Papirus User +Inherits=Adwaita, ../unsafe, hicolor +Directories=48x48/apps +`) + writeThemeIndex(t, system, "Papirus", `[Icon Theme] +Name=Papirus System +`) + writeThemeIndex(t, system, "Adwaita", `[Icon Theme] +Name=Adwaita +Directories=48x48/apps +`) + writeThemeIndex(t, user, "Hidden", `[Icon Theme] +Name=Hidden Theme +Hidden=TRUE +`) + writeThemeIndex(t, user, "Fallback", `[Broken Group +Name=Broken +`) + writeThemeIndex(t, system, "Fallback", `[Icon Theme] +Name=Fallback System +`) + writeThemeIndex(t, user, "NoIndex", ``) + if err := os.Mkdir(filepath.Join(user, "PlainDirectory"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(user, "NotADirectory"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + catalog := NewCatalogWithRoots([]Root{ + {Path: user, Origin: OriginUser}, + {Path: system, Origin: OriginSystem}, + }) + got, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []ThemeSummary{ + {ID: "Adwaita", Name: "Adwaita", Origin: OriginSystem, HasPreview: true}, + {ID: "Fallback", Name: "Fallback System", Origin: OriginSystem}, + { + ID: "Papirus", + Name: "Papirus User", + Inherits: []string{"Adwaita", "hicolor"}, + Origin: OriginUser, + HasPreview: true, + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Refresh() = %#v, want %#v", got, want) + } +} + +func TestCatalogPreviewUsesLowerPrecedenceFragmentMetadata(t *testing.T) { + t.Parallel() + + user := t.TempDir() + system := t.TempDir() + writeThemeIndex(t, user, "Split", "[Icon Theme]\nName=Split User\n") + systemTheme := writeThemeIndex(t, system, "Split", `[Icon Theme] +Name=Split System +Directories=64x64/places + +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + writeSolidPNG(t, filepath.Join(systemTheme, "64x64/places/folder.png"), 64, color.RGBA{B: 255, A: 255}) + + catalog := NewCatalogWithRoots([]Root{ + {Path: user, Origin: OriginUser}, + {Path: system, Origin: OriginSystem}, + }) + items, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Name != "Split User" || !items[0].HasPreview { + t.Fatalf("split summary = %#v, want user metadata with preview", items) + } + preview, err := catalog.Preview(context.Background(), "Split") + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 1 || preview.Samples[0].Kind != "folder" { + t.Fatalf("split preview = %#v, want lower-fragment folder", preview) + } +} + +func TestCatalogInheritedThemeAdvertisesLazyPreview(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeThemeIndex(t, root, "Child", "[Icon Theme]\nName=Child\nInherits=Parent\n") + parent := writeThemeIndex(t, root, "Parent", `[Icon Theme] +Name=Parent +Directories=64x64/places + +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + writeSolidPNG(t, filepath.Join(parent, "64x64/places/folder.png"), 64, color.RGBA{G: 255, A: 255}) + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + items, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, item := range items { + if item.ID == "Child" && !item.HasPreview { + t.Fatal("inherited theme does not advertise its available lazy preview") + } + } +} + +func TestCatalogThemeLimitCountsValidThemes(t *testing.T) { + root := t.TempDir() + for i := 0; i < MaxThemes; i++ { + id := fmt.Sprintf("Invalid-%04d", i) + if err := os.Mkdir(filepath.Join(root, id), 0o755); err != nil { + t.Fatal(err) + } + } + writeThemeIndex(t, root, "Valid-After-Invalid", "[Icon Theme]\nName=Valid\n") + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + items, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].ID != "Valid-After-Invalid" { + t.Fatalf("Refresh() = %#v, want valid theme after invalid candidates", items) + } +} + +func TestCatalogAllowsContainedThemeSymlinkAndRejectsEscape(t *testing.T) { + t.Parallel() + + user := t.TempDir() + system := t.TempDir() + outside := t.TempDir() + writeThemeIndex(t, system, "ContainedTarget", `[Icon Theme] +Name=Contained +`) + writeThemeIndex(t, outside, "OutsideTarget", `[Icon Theme] +Name=Outside +`) + if err := os.Symlink(filepath.Join(system, "ContainedTarget"), filepath.Join(user, "Alias")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "OutsideTarget"), filepath.Join(user, "Escape")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(user, "missing"), filepath.Join(user, "Broken")); err != nil { + t.Fatal(err) + } + + catalog := NewCatalogWithRoots([]Root{ + {Path: user, Origin: OriginUser}, + {Path: system, Origin: OriginSystem}, + }) + got, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + want := []ThemeSummary{ + {ID: "Alias", Name: "Contained", Origin: OriginUser}, + {ID: "ContainedTarget", Name: "Contained", Origin: OriginSystem}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Refresh() = %#v, want %#v", got, want) + } +} + +func writeThemeIndex(t *testing.T, root, id, content string) string { + t.Helper() + dir := filepath.Join(root, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if content != "" { + if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} diff --git a/internal/icontheme/metadata.go b/internal/icontheme/metadata.go new file mode 100644 index 00000000..e6173207 --- /dev/null +++ b/internal/icontheme/metadata.go @@ -0,0 +1,202 @@ +package icontheme + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" +) + +type directoryMetadata struct { + name string + size int + scale int + kind string + minSize int + maxSize int + threshold int + context string +} + +type themeMetadata struct { + name string + hidden bool + inherits []string + directories []directoryMetadata +} + +func parseThemeMetadata(path string, roots []canonicalRoot) (themeMetadata, error) { + resolved, err := resolveContained(path, roots, false) + if err != nil { + return themeMetadata{}, err + } + data, err := readBoundedRegularFile(resolved, MaxMetadataBytes) + if err != nil { + return themeMetadata{}, err + } + sections, err := parseINI(data) + if err != nil { + return themeMetadata{}, err + } + main, ok := sections["Icon Theme"] + if !ok { + return themeMetadata{}, errors.New("missing Icon Theme group") + } + + metadata := themeMetadata{ + name: safeDisplayName(main["Name"]), + hidden: strings.EqualFold(strings.TrimSpace(main["Hidden"]), "true"), + } + for _, value := range splitCSV(main["Inherits"]) { + if ValidateID(value) == nil && !containsString(metadata.inherits, value) { + metadata.inherits = append(metadata.inherits, value) + } + } + directoryNames := append(splitCSV(main["Directories"]), splitCSV(main["ScaledDirectories"])...) + seenDirectories := make(map[string]struct{}) + for _, name := range directoryNames { + if !safeRelativeDirectory(name) { + continue + } + if _, ok := seenDirectories[name]; ok { + continue + } + seenDirectories[name] = struct{}{} + section := sections[name] + metadata.directories = append(metadata.directories, directoryMetadata{ + name: name, + size: boundedPositiveInt(section["Size"], 0, 4096), + scale: boundedPositiveInt(section["Scale"], 1, 16), + kind: strings.ToLower(strings.TrimSpace(section["Type"])), + minSize: boundedPositiveInt(section["MinSize"], 0, 4096), + maxSize: boundedPositiveInt(section["MaxSize"], 0, 4096), + threshold: boundedPositiveInt(section["Threshold"], 2, 4096), + context: strings.TrimSpace(section["Context"]), + }) + } + return metadata, nil +} + +func readBoundedRegularFile(path string, limit int64) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() > limit { + return nil, errors.New("file is not a bounded regular file") + } + data, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("file exceeds size limit") + } + return data, nil +} + +func parseINI(data []byte) (map[string]map[string]string, error) { + sections := make(map[string]map[string]string) + section := "" + scanner := bufio.NewScanner(bytes.NewReader(data)) + // Scanner's maximum also accounts for the delimiter, so reserve enough + // room to inspect and explicitly enforce the advertised line-byte limit. + scanner.Buffer(make([]byte, 1024), MaxMetadataLineBytes+2) + for scanner.Scan() { + if len(scanner.Bytes()) > MaxMetadataLineBytes { + return nil, errors.New("INI line exceeds size limit") + } + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") { + if !strings.HasSuffix(line, "]") || len(line) < 3 { + return nil, errors.New("malformed INI group") + } + section = strings.TrimSpace(line[1 : len(line)-1]) + if section == "" { + return nil, errors.New("empty INI group") + } + if sections[section] == nil { + sections[section] = make(map[string]string) + } + continue + } + if section == "" { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key != "" { + sections[section][key] = strings.TrimSpace(value) + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read INI: %w", err) + } + return sections, nil +} + +func safeDisplayName(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > 512 { + return "" + } + for _, r := range value { + if unicode.IsControl(r) { + return "" + } + } + return value +} + +func safeRelativeDirectory(value string) bool { + if value == "" || filepath.IsAbs(value) || strings.Contains(value, `\`) { + return false + } + clean := filepath.Clean(value) + return clean == value && clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) +} + +func splitCSV(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + result = append(result, value) + } + } + return result +} + +func boundedPositiveInt(value string, fallback, maximum int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed < 0 || parsed > maximum { + return fallback + } + return parsed +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/icontheme/model.go b/internal/icontheme/model.go new file mode 100644 index 00000000..8bc1538f --- /dev/null +++ b/internal/icontheme/model.go @@ -0,0 +1,84 @@ +// Package icontheme owns installed icon-theme selection and catalog behavior. +package icontheme + +import ( + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +// MaxIDBytes bounds a theme directory ID to a common filesystem component +// limit while permitting non-ASCII UTF-8 names. +const MaxIDBytes = 255 + +// SelectionMode identifies how Aether chooses the generated desktop icon theme. +type SelectionMode string + +const ( + SelectionAutomatic SelectionMode = "automatic" + SelectionExplicit SelectionMode = "explicit" +) + +// Selection is the persisted icon-theme choice. +type Selection struct { + Mode SelectionMode `json:"mode"` + ID string `json:"id,omitempty"` +} + +// Automatic returns the canonical automatic selection. +func Automatic() Selection { + return Selection{Mode: SelectionAutomatic} +} + +// ValidateID validates one installed icon-theme directory ID. +func ValidateID(id string) error { + if id == "" { + return errors.New("icon theme ID is empty") + } + if !utf8.ValidString(id) { + return errors.New("icon theme ID is not valid UTF-8") + } + if len(id) > MaxIDBytes { + return fmt.Errorf("icon theme ID exceeds %d bytes", MaxIDBytes) + } + if id == "." || id == ".." { + return errors.New("icon theme ID is not a directory name") + } + if strings.TrimSpace(id) != id { + return errors.New("icon theme ID has leading or trailing whitespace") + } + if strings.ContainsAny(id, `/\`) { + return errors.New("icon theme ID must be one path segment") + } + for _, r := range id { + if unicode.IsControl(r) { + return errors.New("icon theme ID contains a control character") + } + } + return nil +} + +// NormalizeSelection validates selection and canonicalizes legacy zero values. +func NormalizeSelection(selection Selection) (Selection, error) { + switch selection.Mode { + case "": + if selection.ID != "" { + return Selection{}, errors.New("icon theme selection has an ID without a mode") + } + return Automatic(), nil + case SelectionAutomatic: + if selection.ID != "" { + return Selection{}, errors.New("automatic icon theme selection must not have an ID") + } + return Automatic(), nil + case SelectionExplicit: + if err := ValidateID(selection.ID); err != nil { + return Selection{}, fmt.Errorf("explicit icon theme selection: %w", err) + } + return selection, nil + default: + return Selection{}, fmt.Errorf("unknown icon theme selection mode %q", selection.Mode) + } +} diff --git a/internal/icontheme/model_test.go b/internal/icontheme/model_test.go new file mode 100644 index 00000000..52d403f5 --- /dev/null +++ b/internal/icontheme/model_test.go @@ -0,0 +1,118 @@ +package icontheme + +import ( + "strings" + "testing" +) + +func TestValidateID(t *testing.T) { + t.Parallel() + + valid := []string{ + "Papirus-Dark", + "Tela.circle_blue", + "Breeze (Dark)", + "Íconos-日本語", + strings.Repeat("a", 255), + } + for _, id := range valid { + id := id + t.Run("valid/"+id, func(t *testing.T) { + t.Parallel() + if err := ValidateID(id); err != nil { + t.Errorf("ValidateID(%q) = %v, want nil", id, err) + } + }) + } + + invalid := []string{ + "", + ".", + "..", + "/absolute", + `C:\icons`, + "parent/child", + `parent\child`, + " leading", + "trailing ", + "line\nfeed", + "carriage\rreturn", + "nul\x00byte", + "control\u0085byte", + string([]byte{'b', 'a', 'd', 0xff}), + strings.Repeat("a", 256), + } + for _, id := range invalid { + id := id + t.Run("invalid/"+id, func(t *testing.T) { + t.Parallel() + if err := ValidateID(id); err == nil { + t.Errorf("ValidateID(%q) = nil, want error", id) + } + }) + } +} + +func TestNormalizeSelection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input Selection + want Selection + wantErr bool + }{ + {name: "legacy zero", input: Selection{}, want: Automatic()}, + {name: "automatic", input: Automatic(), want: Automatic()}, + { + name: "explicit safe missing ID", + input: Selection{Mode: SelectionExplicit, ID: "Missing-But-Safe"}, + want: Selection{Mode: SelectionExplicit, ID: "Missing-But-Safe"}, + }, + { + name: "automatic with ID", + input: Selection{Mode: SelectionAutomatic, ID: "Papirus"}, + wantErr: true, + }, + { + name: "explicit without ID", + input: Selection{Mode: SelectionExplicit}, + wantErr: true, + }, + { + name: "explicit unsafe ID", + input: Selection{Mode: SelectionExplicit, ID: "../escape"}, + wantErr: true, + }, + { + name: "unknown mode", + input: Selection{Mode: "installed", ID: "Papirus"}, + wantErr: true, + }, + { + name: "missing mode with ID", + input: Selection{ID: "Papirus"}, + wantErr: true, + }, + } + + for _, tt := range tests { + tc := tt + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := NormalizeSelection(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("NormalizeSelection(%+v) = %+v, nil; want error", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("NormalizeSelection(%+v) error = %v", tc.input, err) + } + if got != tc.want { + t.Errorf("NormalizeSelection(%+v) = %+v, want %+v", tc.input, got, tc.want) + } + }) + } +} diff --git a/internal/icontheme/preview.go b/internal/icontheme/preview.go new file mode 100644 index 00000000..c26fea68 --- /dev/null +++ b/internal/icontheme/preview.go @@ -0,0 +1,381 @@ +package icontheme + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "path/filepath" + "sort" + "strconv" + "strings" + + xdraw "golang.org/x/image/draw" +) + +const ( + MaxInheritanceDepth = 32 + MaxVisitedInheritedThemes = 128 + MaxCandidatesPerConcept = 512 + MaxSourceIconBytes int64 = 8 << 20 + MaxRasterDimension = 2048 + MaxPreviewSamples = 3 + MaxPreviewCacheEntries = 256 + MaxXPMColors = 4096 +) + +type previewConcept struct { + kind string + names []string + contexts []string +} + +var previewConcepts = []previewConcept{ + { + kind: "folder", + names: []string{"folder", "folder-open", "inode-directory"}, + contexts: []string{"places"}, + }, + { + kind: "utility", + names: []string{"utilities-terminal", "terminal", "system-run"}, + contexts: []string{"applications", "devices"}, + }, + { + kind: "application", + names: []string{"web-browser", "internet-web-browser", "application-x-executable"}, + contexts: []string{"applications"}, + }, +} + +// Preview returns safe raster samples for an installed theme ID. +func (c *Catalog) Preview(ctx context.Context, themeID string) (ThemePreview, error) { + if err := ValidateID(themeID); err != nil { + return ThemePreview{}, fmt.Errorf("invalid icon theme ID: %w", err) + } + if _, err := c.List(ctx); err != nil { + return ThemePreview{}, err + } + + c.mu.RLock() + generation := c.generation + c.mu.RUnlock() + cacheKey := fmt.Sprintf("%d:%s", generation, themeID) + c.previewMu.Lock() + if cached, ok := c.previewCache[cacheKey]; ok { + result := clonePreview(cached) + c.previewMu.Unlock() + return result, nil + } + c.previewMu.Unlock() + select { + case c.previewSem <- struct{}{}: + defer func() { <-c.previewSem }() + case <-ctx.Done(): + return ThemePreview{}, ctx.Err() + } + + c.mu.RLock() + record, ok := c.themes[themeID] + themes := c.themes + roots := append([]Root(nil), c.roots...) + c.mu.RUnlock() + if !ok { + return ThemePreview{}, fmt.Errorf("icon theme %q is not installed", themeID) + } + canonicalRoots := canonicalizeRoots(roots) + if len(canonicalRoots) == 0 { + return ThemePreview{}, errors.New("no readable icon roots") + } + + result := ThemePreview{ThemeID: themeID, Samples: make([]PreviewSample, 0, MaxPreviewSamples)} + for _, concept := range previewConcepts { + if err := ctx.Err(); err != nil { + return ThemePreview{}, err + } + imageValue, found := findConceptImage(ctx, record, themes, canonicalRoots, concept) + if !found { + continue + } + encoded, err := rasterizePNG(imageValue) + if err != nil { + continue + } + result.Samples = append(result.Samples, PreviewSample{ + Kind: concept.kind, + PNGData: encodePNGDataURL(encoded), + }) + } + + c.previewMu.Lock() + if len(c.previewOrder) >= MaxPreviewCacheEntries { + oldest := c.previewOrder[0] + c.previewOrder = c.previewOrder[1:] + delete(c.previewCache, oldest) + } + c.previewCache[cacheKey] = clonePreview(result) + c.previewOrder = append(c.previewOrder, cacheKey) + c.previewMu.Unlock() + return clonePreview(result), nil +} + +func findConceptImage( + ctx context.Context, + root *themeRecord, + themes map[string]*themeRecord, + approvedRoots []canonicalRoot, + concept previewConcept, +) (image.Image, bool) { + visited := make(map[string]bool) + examined := 0 + var visit func(*themeRecord, int) (image.Image, bool) + visit = func(record *themeRecord, depth int) (image.Image, bool) { + if record == nil || depth > MaxInheritanceDepth || len(visited) >= MaxVisitedInheritedThemes || visited[record.id] { + return nil, false + } + visited[record.id] = true + if img, ok := findImageInRecord(ctx, record, approvedRoots, concept, &examined); ok { + return img, true + } + for _, inheritedID := range record.metadata.inherits { + if img, ok := visit(themes[inheritedID], depth+1); ok { + return img, true + } + } + return nil, false + } + return visit(root, 0) +} + +func findImageInRecord( + ctx context.Context, + record *themeRecord, + approvedRoots []canonicalRoot, + concept previewConcept, + examined *int, +) (image.Image, bool) { + for _, fragment := range record.fragments { + directories := append([]directoryMetadata(nil), record.metadata.directories...) + if fragment.metadata != nil { + directories = append(directories[:0], fragment.metadata.directories...) + } + sort.SliceStable(directories, func(i, j int) bool { + return directoryScore(directories[i], concept) < directoryScore(directories[j], concept) + }) + for _, directory := range directories { + for _, name := range concept.names { + for _, extension := range []string{".png", ".xpm", ".svg", ".svgz"} { + if *examined >= MaxCandidatesPerConcept || ctx.Err() != nil { + return nil, false + } + *examined++ + candidate := filepath.Join(fragment.path, filepath.FromSlash(directory.name), name+extension) + resolved, err := resolveContained(candidate, approvedRoots, false) + if err != nil { + continue + } + switch extension { + case ".png": + if img, err := decodeBoundedPNG(resolved); err == nil { + return img, true + } + case ".xpm": + if img, err := decodeBoundedXPM(resolved); err == nil { + return img, true + } + default: + // Raw SVG/SVGZ is never decoded or returned. A future renderer + // must prove script, external-reference, and resource bounds. + } + } + } + } + } + return nil, false +} + +func directoryScore(directory directoryMetadata, concept previewConcept) int { + contextPenalty := 10000 + for _, preferred := range concept.contexts { + if strings.EqualFold(directory.context, preferred) { + contextPenalty = 0 + break + } + } + size := directory.size * max(directory.scale, 1) + if directory.kind == "scalable" { + minSize := directory.minSize * max(directory.scale, 1) + maxSize := directory.maxSize * max(directory.scale, 1) + if minSize > 0 && PreviewSize < minSize { + size = minSize + } else if maxSize > 0 && PreviewSize > maxSize { + size = maxSize + } else { + size = PreviewSize + } + } + if size == 0 { + size = PreviewSize * 4 + } + return contextPenalty + abs(size-PreviewSize) +} + +func decodeBoundedPNG(path string) (image.Image, error) { + data, err := readBoundedRegularFile(path, MaxSourceIconBytes) + if err != nil { + return nil, err + } + config, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, err + } + if config.Width <= 0 || config.Height <= 0 || config.Width > MaxRasterDimension || config.Height > MaxRasterDimension { + return nil, errors.New("PNG dimensions exceed preview bounds") + } + return png.Decode(bytes.NewReader(data)) +} + +func decodeBoundedXPM(path string) (image.Image, error) { + data, err := readBoundedRegularFile(path, MaxSourceIconBytes) + if err != nil { + return nil, err + } + lines := xpmStrings(data) + if len(lines) == 0 { + return nil, errors.New("XPM has no string data") + } + header := strings.Fields(lines[0]) + if len(header) < 4 { + return nil, errors.New("invalid XPM header") + } + width, errWidth := strconv.Atoi(header[0]) + height, errHeight := strconv.Atoi(header[1]) + colorCount, errColors := strconv.Atoi(header[2]) + charsPerPixel, errCPP := strconv.Atoi(header[3]) + if errWidth != nil || errHeight != nil || errColors != nil || errCPP != nil || + width <= 0 || height <= 0 || width > MaxRasterDimension || height > MaxRasterDimension || + colorCount <= 0 || colorCount > MaxXPMColors || charsPerPixel <= 0 || charsPerPixel > 4 { + return nil, errors.New("XPM header exceeds bounds") + } + if len(lines) < 1+colorCount+height { + return nil, errors.New("truncated XPM") + } + palette := make(map[string]color.Color, colorCount) + for _, line := range lines[1 : 1+colorCount] { + if len(line) < charsPerPixel { + return nil, errors.New("invalid XPM color entry") + } + key := line[:charsPerPixel] + fields := strings.Fields(line[charsPerPixel:]) + value := "" + for i := 0; i+1 < len(fields); i++ { + if fields[i] == "c" { + value = fields[i+1] + break + } + } + parsed, err := parseXPMColor(value) + if err != nil { + return nil, err + } + palette[key] = parsed + } + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for y, line := range lines[1+colorCount : 1+colorCount+height] { + if len(line) != width*charsPerPixel { + return nil, errors.New("invalid XPM pixel row") + } + for x := 0; x < width; x++ { + key := line[x*charsPerPixel : (x+1)*charsPerPixel] + value, ok := palette[key] + if !ok { + return nil, errors.New("unknown XPM palette key") + } + img.Set(x, y, value) + } + } + return img, nil +} + +func xpmStrings(data []byte) []string { + lines := strings.Split(string(data), "\n") + result := make([]string, 0, len(lines)) + for _, line := range lines { + start := strings.IndexByte(line, '"') + end := strings.LastIndexByte(line, '"') + if start < 0 || end <= start { + continue + } + value, err := strconv.Unquote(line[start : end+1]) + if err == nil { + result = append(result, value) + } + } + return result +} + +func parseXPMColor(value string) (color.Color, error) { + if strings.EqualFold(value, "None") { + return color.NRGBA{}, nil + } + if len(value) == 4 && value[0] == '#' { + r, errR := strconv.ParseUint(strings.Repeat(value[1:2], 2), 16, 8) + g, errG := strconv.ParseUint(strings.Repeat(value[2:3], 2), 16, 8) + b, errB := strconv.ParseUint(strings.Repeat(value[3:4], 2), 16, 8) + if errR == nil && errG == nil && errB == nil { + return color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}, nil + } + } + if len(value) == 7 && value[0] == '#' { + parsed, err := strconv.ParseUint(value[1:], 16, 32) + if err == nil { + return color.NRGBA{R: uint8(parsed >> 16), G: uint8(parsed >> 8), B: uint8(parsed), A: 255}, nil + } + } + return nil, errors.New("unsupported XPM color") +} + +func rasterizePNG(source image.Image) ([]byte, error) { + bounds := source.Bounds() + if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx() > MaxRasterDimension || bounds.Dy() > MaxRasterDimension { + return nil, errors.New("source dimensions exceed preview bounds") + } + scale := math.Min(float64(PreviewSize)/float64(bounds.Dx()), float64(PreviewSize)/float64(bounds.Dy())) + width := max(1, int(math.Round(float64(bounds.Dx())*scale))) + height := max(1, int(math.Round(float64(bounds.Dy())*scale))) + resized := image.NewNRGBA(image.Rect(0, 0, width, height)) + xdraw.CatmullRom.Scale(resized, resized.Bounds(), source, bounds, draw.Over, nil) + canvas := image.NewNRGBA(image.Rect(0, 0, PreviewSize, PreviewSize)) + offset := image.Pt((PreviewSize-width)/2, (PreviewSize-height)/2) + draw.Draw(canvas, resized.Bounds().Add(offset), resized, image.Point{}, draw.Over) + var output bytes.Buffer + if err := png.Encode(&output, canvas); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +// encodePNGDataURL is kept in the backend boundary so source bytes and paths +// can never be returned accidentally by a preview implementation. +func encodePNGDataURL(data []byte) string { + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) +} + +func clonePreview(preview ThemePreview) ThemePreview { + result := preview + result.Samples = append([]PreviewSample(nil), preview.Samples...) + return result +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} diff --git a/internal/icontheme/preview_test.go b/internal/icontheme/preview_test.go new file mode 100644 index 00000000..a55d4f59 --- /dev/null +++ b/internal/icontheme/preview_test.go @@ -0,0 +1,302 @@ +package icontheme + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestPreviewRasterizesBoundedPNGAndXPMWithInheritance(t *testing.T) { + t.Parallel() + + root := t.TempDir() + child := writeThemeIndex(t, root, "Child", `[Icon Theme] +Name=Child +Inherits=Parent +Directories=16x16/places,64x64/places,64x64/apps + +[16x16/places] +Size=16 +Context=Places +Type=Fixed + +[64x64/places] +Size=64 +Context=Places +Type=Fixed + +[64x64/apps] +Size=64 +Context=Applications +Type=Fixed +`) + parent := writeThemeIndex(t, root, "Parent", `[Icon Theme] +Name=Parent +Directories=64x64/apps + +[64x64/apps] +Size=64 +Context=Applications +Type=Fixed +`) + writeSolidPNG(t, filepath.Join(child, "16x16/places/folder.png"), 16, color.RGBA{R: 255, A: 255}) + writeSolidPNG(t, filepath.Join(child, "64x64/places/folder.png"), 64, color.RGBA{B: 255, A: 255}) + writeXPM(t, filepath.Join(child, "64x64/apps/web-browser.xpm")) + writeSolidPNG(t, filepath.Join(parent, "64x64/apps/utilities-terminal.png"), 64, color.RGBA{G: 255, A: 255}) + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + preview, err := catalog.Preview(context.Background(), "Child") + if err != nil { + t.Fatal(err) + } + if preview.ThemeID != "Child" { + t.Errorf("ThemeID = %q, want Child", preview.ThemeID) + } + if len(preview.Samples) != 3 { + t.Fatalf("sample count = %d, want 3: %#v", len(preview.Samples), preview.Samples) + } + wantKinds := []string{"folder", "utility", "application"} + for i, sample := range preview.Samples { + if sample.Kind != wantKinds[i] { + t.Errorf("sample %d kind = %q, want %q", i, sample.Kind, wantKinds[i]) + } + decoded := decodePreviewPNG(t, sample.PNGData) + if decoded.Bounds().Dx() > PreviewSize || decoded.Bounds().Dy() > PreviewSize { + t.Errorf("sample %d dimensions = %v, exceed %d", i, decoded.Bounds(), PreviewSize) + } + } + + // The 64px folder is closest to the preview target, so blue must win over + // the red 16px candidate. + folder := decodePreviewPNG(t, preview.Samples[0].PNGData) + r, g, b, _ := folder.At(folder.Bounds().Dx()/2, folder.Bounds().Dy()/2).RGBA() + if b <= r || b <= g { + t.Errorf("folder center RGB16 = (%d,%d,%d), want the blue 64px candidate", r, g, b) + } + + data, err := json.Marshal(preview) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{root, ".svg", ".xpm", "file://"} { + if bytes.Contains(data, []byte(forbidden)) { + t.Errorf("public preview JSON contains forbidden source detail %q: %s", forbidden, data) + } + } +} + +func TestPreviewRejectsSVGMarkupAndEscapingSymlink(t *testing.T) { + t.Parallel() + + root := t.TempDir() + outside := t.TempDir() + theme := writeThemeIndex(t, root, "SVGOnly", `[Icon Theme] +Name=SVG Only +Directories=64x64/apps + +[64x64/apps] +Size=64 +Context=Applications +Type=Fixed +`) + apps := filepath.Join(theme, "64x64/apps") + if err := os.MkdirAll(apps, 0o755); err != nil { + t.Fatal(err) + } + malicious := `` + if err := os.WriteFile(filepath.Join(apps, "web-browser.svg"), []byte(malicious), 0o600); err != nil { + t.Fatal(err) + } + escaping := filepath.Join(outside, "utilities-terminal.png") + writeSolidPNG(t, escaping, 16, color.RGBA{R: 255, A: 255}) + if err := os.Symlink(escaping, filepath.Join(apps, "utilities-terminal.png")); err != nil { + t.Fatal(err) + } + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + preview, err := catalog.Preview(context.Background(), "SVGOnly") + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 0 { + t.Errorf("unsafe SVG/symlink produced samples: %#v", preview.Samples) + } + data, _ := json.Marshal(preview) + if bytes.Contains(data, []byte("svg")) || bytes.Contains(data, []byte("/etc/passwd")) { + t.Errorf("preview leaked SVG content: %s", data) + } +} + +func TestPreviewRejectsUnsafeOrMissingThemeID(t *testing.T) { + t.Parallel() + + catalog := NewCatalogWithRoots([]Root{{Path: t.TempDir(), Origin: OriginUser}}) + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + for _, id := range []string{"../escape", "Missing"} { + if _, err := catalog.Preview(context.Background(), id); err == nil { + t.Errorf("Preview(%q) error = nil, want rejection", id) + } + } +} + +func TestPreviewCacheIsClearedByRefresh(t *testing.T) { + t.Parallel() + + root := t.TempDir() + theme := writeThemeIndex(t, root, "Mutable", `[Icon Theme] +Name=Mutable +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + iconPath := filepath.Join(theme, "64x64/places/folder.png") + writeSolidPNG(t, iconPath, 64, color.RGBA{R: 255, A: 255}) + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + first, err := catalog.Preview(context.Background(), "Mutable") + if err != nil { + t.Fatal(err) + } + writeSolidPNG(t, iconPath, 64, color.RGBA{B: 255, A: 255}) + cached, err := catalog.Preview(context.Background(), "Mutable") + if err != nil { + t.Fatal(err) + } + if cached.Samples[0].PNGData != first.Samples[0].PNGData { + t.Fatal("Preview cache changed without Refresh") + } + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + refreshed, err := catalog.Preview(context.Background(), "Mutable") + if err != nil { + t.Fatal(err) + } + if refreshed.Samples[0].PNGData == first.Samples[0].PNGData { + t.Fatal("Refresh did not clear the preview cache") + } +} + +func TestConcurrentListRefreshAndPreview(t *testing.T) { + t.Parallel() + + root := t.TempDir() + theme := writeThemeIndex(t, root, "Concurrent", `[Icon Theme] +Name=Concurrent +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + writeSolidPNG(t, filepath.Join(theme, "64x64/places/folder.png"), 64, color.RGBA{G: 255, A: 255}) + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + errors := make(chan error, 30) + for i := 0; i < 10; i++ { + wg.Add(3) + go func() { + defer wg.Done() + _, err := catalog.List(context.Background()) + errors <- err + }() + go func() { + defer wg.Done() + _, err := catalog.Refresh(context.Background()) + errors <- err + }() + go func() { + defer wg.Done() + _, err := catalog.Preview(context.Background(), "Concurrent") + errors <- err + }() + } + wg.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Errorf("concurrent catalog call: %v", err) + } + } +} + +func writeSolidPNG(t *testing.T, path string, size int, c color.Color) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.Set(x, y, c) + } + } + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(file, img); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func writeXPM(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + content := `/* XPM */ +static char * icon[] = { +"2 2 2 1", +". c #ff00ff", +" c None", +". ", +" ."}; +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func decodePreviewPNG(t *testing.T, dataURL string) image.Image { + t.Helper() + const prefix = "data:image/png;base64," + if !strings.HasPrefix(dataURL, prefix) { + t.Fatalf("preview URL does not have PNG data prefix: %.40q", dataURL) + } + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix)) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode returned PNG: %v", err) + } + return img +} diff --git a/internal/icontheme/security_test.go b/internal/icontheme/security_test.go new file mode 100644 index 00000000..a75b738f --- /dev/null +++ b/internal/icontheme/security_test.go @@ -0,0 +1,318 @@ +package icontheme + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image/color" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" +) + +func TestCatalogSkipsMalformedOversizedUnreadableAndSpecialEntries(t *testing.T) { + root := t.TempDir() + writeThemeIndex(t, root, "Localized", "[Icon Theme]\nName=Grüße 世界\n") + writeThemeIndex(t, root, "Malformed", "[Icon Theme\nName=Malformed\n") + oversized := writeThemeIndex(t, root, "Oversized", "[Icon Theme]\nName=Oversized\n") + if err := os.WriteFile(filepath.Join(oversized, "index.theme"), bytes.Repeat([]byte{'x'}, int(MaxMetadataBytes)+1), 0o600); err != nil { + t.Fatal(err) + } + unreadable := writeThemeIndex(t, root, "Unreadable", "[Icon Theme]\nName=Unreadable\n") + unreadableIndex := filepath.Join(unreadable, "index.theme") + if err := os.Chmod(unreadableIndex, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(unreadableIndex, 0o600) }) + if err := syscall.Mkfifo(filepath.Join(root, "NotATheme"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "CycleB"), filepath.Join(root, "CycleA")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "CycleA"), filepath.Join(root, "CycleB")); err != nil { + t.Fatal(err) + } + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + items, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].ID != "Localized" || items[0].Name != "Grüße 世界" { + t.Fatalf("Refresh() = %#v, want only localized valid theme", items) + } + encoded, err := json.Marshal(items) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(encoded, []byte(root)) || bytes.Contains(bytes.ToLower(encoded), []byte("path")) { + t.Fatalf("catalog DTO leaked a host path or path field: %s", encoded) + } +} + +func TestCatalogLargeCollectionStopsAtLogicalThemeBound(t *testing.T) { + root := t.TempDir() + for i := 0; i < MaxThemes+1; i++ { + id := fmt.Sprintf("Theme-%04d", i) + writeThemeIndex(t, root, id, "[Icon Theme]\nName="+id+"\n") + } + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + items, err := catalog.Refresh(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(items) != MaxThemes { + t.Fatalf("Refresh() returned %d themes, want bound %d", len(items), MaxThemes) + } + if items[0].ID != "Theme-0000" || items[len(items)-1].ID != "Theme-1023" { + t.Fatalf("bounded catalog is not deterministic: first=%q last=%q", items[0].ID, items[len(items)-1].ID) + } +} + +func TestPreviewInheritanceOrderMissingParentAndCycle(t *testing.T) { + root := t.TempDir() + writeThemeIndex(t, root, "Root", `[Icon Theme] +Name=Root +Inherits=Missing,Cycle,First,Second +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + writeThemeIndex(t, root, "Cycle", "[Icon Theme]\nName=Cycle\nInherits=Root\n") + first := writeThemeIndex(t, root, "First", `[Icon Theme] +Name=First +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + second := writeThemeIndex(t, root, "Second", `[Icon Theme] +Name=Second +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +Type=Fixed +`) + writeSolidPNG(t, filepath.Join(first, "64x64/places/folder.png"), 8, color.RGBA{G: 255, A: 255}) + writeSolidPNG(t, filepath.Join(second, "64x64/places/folder.png"), 8, color.RGBA{B: 255, A: 255}) + + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + preview, err := catalog.Preview(context.Background(), "Root") + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 1 { + t.Fatalf("Preview() samples = %#v, want inherited folder", preview.Samples) + } + img := decodePreviewPNG(t, preview.Samples[0].PNGData) + r, g, b, _ := img.At(PreviewSize/2, PreviewSize/2).RGBA() + if g <= r || g <= b { + t.Fatalf("inheritance order chose RGB16 (%d,%d,%d), want First/green", r, g, b) + } +} + +func TestPreviewStopsAtInheritanceDepthAndVisitedBounds(t *testing.T) { + t.Run("depth", func(t *testing.T) { + root := t.TempDir() + for i := 0; i <= MaxInheritanceDepth+1; i++ { + id := fmt.Sprintf("Depth-%02d", i) + inherits := "" + if i <= MaxInheritanceDepth { + inherits = fmt.Sprintf("Inherits=Depth-%02d\n", i+1) + } + dir := writeThemeIndex(t, root, id, "[Icon Theme]\nName="+id+"\n"+inherits+"Directories=64x64/places\n[64x64/places]\nSize=64\nContext=Places\n") + if i == MaxInheritanceDepth+1 { + writeSolidPNG(t, filepath.Join(dir, "64x64/places/folder.png"), 1, color.RGBA{R: 255, A: 255}) + } + } + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + preview, err := catalog.Preview(context.Background(), "Depth-00") + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 0 { + t.Fatalf("depth-bounded preview returned samples: %#v", preview.Samples) + } + }) + + t.Run("visited", func(t *testing.T) { + root := t.TempDir() + parents := make([]string, 0, MaxVisitedInheritedThemes+1) + for i := 0; i <= MaxVisitedInheritedThemes; i++ { + id := fmt.Sprintf("Parent-%03d", i) + parents = append(parents, id) + dir := writeThemeIndex(t, root, id, "[Icon Theme]\nName="+id+"\nDirectories=64x64/places\n[64x64/places]\nSize=64\nContext=Places\n") + if i == MaxVisitedInheritedThemes { + writeSolidPNG(t, filepath.Join(dir, "64x64/places/folder.png"), 1, color.RGBA{R: 255, A: 255}) + } + } + writeThemeIndex(t, root, "VisitedRoot", "[Icon Theme]\nName=Visited Root\nInherits="+strings.Join(parents, ",")+"\n") + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + preview, err := catalog.Preview(context.Background(), "VisitedRoot") + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 0 { + t.Fatalf("visited-bounded preview returned samples: %#v", preview.Samples) + } + }) +} + +func TestPreviewContainedFileSymlinkSpecialFileAndRasterDimensionBounds(t *testing.T) { + user := t.TempDir() + system := t.TempDir() + shared := filepath.Join(system, "shared-folder.png") + writeSolidPNG(t, shared, 8, color.RGBA{G: 255, A: 255}) + contained := writeThemeIndex(t, user, "ContainedFile", `[Icon Theme] +Name=Contained File +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +`) + containedDir := filepath.Join(contained, "64x64/places") + if err := os.MkdirAll(containedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(shared, filepath.Join(containedDir, "folder.png")); err != nil { + t.Fatal(err) + } + + special := writeThemeIndex(t, user, "SpecialFile", `[Icon Theme] +Name=Special File +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +`) + specialDir := filepath.Join(special, "64x64/places") + if err := os.MkdirAll(specialDir, 0o755); err != nil { + t.Fatal(err) + } + fifo := filepath.Join(system, "shared.fifo") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(fifo, filepath.Join(specialDir, "folder.png")); err != nil { + t.Fatal(err) + } + + oversized := writeThemeIndex(t, user, "OversizedRaster", `[Icon Theme] +Name=Oversized Raster +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +`) + writeSolidPNG(t, filepath.Join(oversized, "64x64/places/folder.png"), MaxRasterDimension+1, color.RGBA{R: 255, A: 255}) + + catalog := NewCatalogWithRoots([]Root{ + {Path: user, Origin: OriginUser}, + {Path: system, Origin: OriginSystem}, + }) + preview, err := catalog.Preview(context.Background(), "ContainedFile") + if err != nil || len(preview.Samples) != 1 { + t.Fatalf("contained file symlink preview = %#v, %v", preview, err) + } + for _, id := range []string{"SpecialFile", "OversizedRaster"} { + preview, err := catalog.Preview(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if len(preview.Samples) != 0 { + t.Fatalf("Preview(%q) accepted unsafe source: %#v", id, preview.Samples) + } + } +} + +func TestPreviewCacheEvictionIsBounded(t *testing.T) { + root := t.TempDir() + for i := 0; i <= MaxPreviewCacheEntries; i++ { + id := fmt.Sprintf("Cached-%03d", i) + dir := writeThemeIndex(t, root, id, "[Icon Theme]\nName="+id+"\nDirectories=64x64/places\n[64x64/places]\nSize=64\nContext=Places\n") + writeSolidPNG(t, filepath.Join(dir, "64x64/places/folder.png"), 1, color.RGBA{G: 255, A: 255}) + } + catalog := NewCatalogWithRoots([]Root{{Path: root, Origin: OriginUser}}) + for i := 0; i <= MaxPreviewCacheEntries; i++ { + if _, err := catalog.Preview(context.Background(), fmt.Sprintf("Cached-%03d", i)); err != nil { + t.Fatal(err) + } + } + catalog.previewMu.Lock() + defer catalog.previewMu.Unlock() + if len(catalog.previewCache) != MaxPreviewCacheEntries || len(catalog.previewOrder) != MaxPreviewCacheEntries { + t.Fatalf("preview cache sizes = %d/%d, want %d", len(catalog.previewCache), len(catalog.previewOrder), MaxPreviewCacheEntries) + } + firstKey := fmt.Sprintf("%d:Cached-000", catalog.generation) + if _, ok := catalog.previewCache[firstKey]; ok { + t.Fatal("oldest preview cache entry was not evicted") + } +} + +func TestPreviewRaceReplacedSymlinkNeverEscapesApprovedRoots(t *testing.T) { + approved := t.TempDir() + outside := t.TempDir() + theme := writeThemeIndex(t, approved, "Raced", `[Icon Theme] +Name=Raced +Directories=64x64/places +[64x64/places] +Size=64 +Context=Places +`) + insideIcon := filepath.Join(approved, "inside.png") + outsideIcon := filepath.Join(outside, "outside.png") + writeSolidPNG(t, insideIcon, 4, color.RGBA{G: 255, A: 255}) + writeSolidPNG(t, outsideIcon, 4, color.RGBA{R: 255, A: 255}) + link := filepath.Join(theme, "64x64/places/folder.png") + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(insideIcon, link); err != nil { + t.Fatal(err) + } + + catalog := NewCatalogWithRoots([]Root{{Path: approved, Origin: OriginUser}}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + _ = os.Remove(link) + target := insideIcon + if i%2 == 1 { + target = outsideIcon + } + _ = os.Symlink(target, link) + } + }() + for i := 0; i < 100; i++ { + if _, err := catalog.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + preview, err := catalog.Preview(context.Background(), "Raced") + if err != nil { + if strings.Contains(err.Error(), outside) { + t.Fatalf("frontend-facing error leaked outside path: %v", err) + } + continue + } + if len(preview.Samples) == 0 { + continue + } + img := decodePreviewPNG(t, preview.Samples[0].PNGData) + r, g, _, _ := img.At(PreviewSize/2, PreviewSize/2).RGBA() + if r > g { + t.Fatal("race-replaced escaping symlink produced outside raster data") + } + } + wg.Wait() +} diff --git a/internal/theme/history_test.go b/internal/theme/history_test.go new file mode 100644 index 00000000..3f61eea3 --- /dev/null +++ b/internal/theme/history_test.go @@ -0,0 +1,24 @@ +package theme + +import ( + "testing" + + "aether/internal/icontheme" +) + +func TestHistoryRoundTripsIconThemeSelection(t *testing.T) { + history := NewHistoryManager() + automatic := *NewThemeState() + explicit := automatic + explicit.IconTheme = icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"} + + history.Push(automatic) + restored, ok := history.Undo(explicit) + if !ok || restored.IconTheme != icontheme.Automatic() { + t.Fatalf("Undo() = %+v, %v; want Automatic", restored.IconTheme, ok) + } + restored, ok = history.Redo(restored) + if !ok || restored.IconTheme != explicit.IconTheme { + t.Fatalf("Redo() = %+v, %v; want %+v", restored.IconTheme, ok, explicit.IconTheme) + } +} diff --git a/internal/theme/state.go b/internal/theme/state.go index a6c71cd1..56a9010b 100644 --- a/internal/theme/state.go +++ b/internal/theme/state.go @@ -2,6 +2,7 @@ package theme import ( "aether/internal/color" + "aether/internal/icontheme" "aether/internal/template" ) @@ -18,6 +19,7 @@ type ThemeState struct { ExtractionMode string `json:"extractionMode"` AdditionalImages []string `json:"additionalImages"` AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // DefaultPalette is the Catppuccin-inspired default 16-color palette. @@ -51,6 +53,7 @@ func NewThemeState() *ThemeState { ExtractionMode: "auto", AdditionalImages: []string{}, AppOverrides: make(map[string]map[string]string), + IconTheme: icontheme.Automatic(), } s.ColorRoles = s.buildColorRoles() return s @@ -91,6 +94,7 @@ type StateSnapshot struct { ExtractionMode string `json:"extractionMode"` AdditionalImages []string `json:"additionalImages"` AppOverrides map[string]map[string]string `json:"appOverrides"` + IconTheme icontheme.Selection `json:"iconTheme"` } // Snapshot returns a copy of the current state suitable for Wails binding. @@ -127,6 +131,7 @@ func (s *ThemeState) Snapshot() StateSnapshot { ExtractionMode: s.ExtractionMode, AdditionalImages: images, AppOverrides: overrides, + IconTheme: s.IconTheme, } } diff --git a/internal/theme/state_test.go b/internal/theme/state_test.go index 44ea8889..d8753e93 100644 --- a/internal/theme/state_test.go +++ b/internal/theme/state_test.go @@ -1,6 +1,22 @@ package theme -import "testing" +import ( + "testing" + + "aether/internal/icontheme" +) + +func TestThemeStateIconThemeDefaultsAndSnapshots(t *testing.T) { + state := NewThemeState() + if state.IconTheme != icontheme.Automatic() { + t.Errorf("default IconTheme = %+v, want Automatic", state.IconTheme) + } + want := (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"}) + state.IconTheme = want + if got := state.Snapshot().IconTheme; got != want { + t.Errorf("snapshot IconTheme = %+v, want %+v", got, want) + } +} // Both import paths (CLI runImportColorsToml and the GUI stageImportIntoState) // rely on the contract that setting ExtendedColors before SetPalette makes diff --git a/internal/theme/writer.go b/internal/theme/writer.go index f6d4b4e3..2afef309 100644 --- a/internal/theme/writer.go +++ b/internal/theme/writer.go @@ -11,6 +11,7 @@ import ( "strings" "aether/internal/color" + "aether/internal/icontheme" "aether/internal/platform" "aether/internal/template" ) @@ -86,6 +87,30 @@ func (s Settings) includesApp(app string) bool { } } +// includesOmarchyV4App preserves the v4 renderer's explicit target map. +func (s Settings) includesOmarchyV4App(app string) bool { + return s.IncludedApps != nil && s.IncludedApps[app] +} + +// withLegacyOmarchyV4Icons bridges public callers that predate IncludedApps. +// Omarchy v4 otherwise keeps its established explicit-only target semantics. +func (s Settings) withLegacyOmarchyV4Icons() Settings { + if s.IncludedApps == nil { + s.IncludedApps = map[string]bool{"icons": true} + } + return s +} + +func validateIconTheme(selection icontheme.Selection, enabled bool) error { + if !enabled { + return nil + } + if _, err := icontheme.NormalizeSelection(selection); err != nil { + return fmt.Errorf("icon theme: %w", err) + } + return nil +} + // DefaultApplySettings returns the same defaults `aether --generate` uses. // Use this anywhere the user is applying a theme without picking per-app // toggles explicitly so a bare `Settings{}` does not silently drop editor @@ -211,6 +236,26 @@ func (w *Writer) processOmarchyV4Templates( appOverrides map[string]map[string]string, globalOverrides map[string]string, ) { + if err := w.processOmarchyV4TemplatesWithIconTheme( + themeDir, + variables, + settings, + appOverrides, + globalOverrides, + icontheme.Automatic(), + ); err != nil { + log.Printf("Error processing Omarchy v4 templates: %v", err) + } +} + +func (w *Writer) processOmarchyV4TemplatesWithIconTheme( + themeDir string, + variables map[string]string, + settings Settings, + appOverrides map[string]map[string]string, + globalOverrides map[string]string, + iconTheme icontheme.Selection, +) error { w.processTemplate( "colors.v4.toml", filepath.Join(themeDir, "colors.toml"), @@ -221,8 +266,7 @@ func (w *Writer) processOmarchyV4Templates( names, err := template.ListTemplates(w.templatesFS, w.templatesDir) if err != nil { - log.Printf("Error listing templates: %v", err) - return + return fmt.Errorf("list templates: %w", err) } for _, fileName := range names { if fileName == "copy.json" || fileName == "colors.toml" || fileName == "colors.v4.toml" { @@ -230,7 +274,8 @@ func (w *Writer) processOmarchyV4Templates( } appName := getAppNameFromFileName(fileName) - if !settings.IncludedApps[appName] && len(appOverrides[appName]) == 0 { + if !settings.includesOmarchyV4App(appName) && + (appName == "icons" || len(appOverrides[appName]) == 0) { continue } @@ -245,22 +290,39 @@ func (w *Writer) processOmarchyV4Templates( } continue } + if fileName == "icons.theme" { + if err := w.writeIconTheme(outputPath, variables, appOverrides, globalOverrides, iconTheme); err != nil { + return err + } + continue + } w.processTemplate(fileName, outputPath, variables, appOverrides, globalOverrides) } + return nil } // GenerateOmarchyV4Only writes a reusable Omarchy v4 theme folder. App-specific // templates are included only when targeted or needed by a color override. func (w *Writer) GenerateOmarchyV4Only(state *ThemeState, settings Settings, outputPath string) error { + settings = settings.withLegacyOmarchyV4Icons() variables := template.BuildVariables(state.ColorRoles, state.LightMode, state.ExtendedColors) if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return err } + if err := validateIconTheme(state.IconTheme, settings.includesOmarchyV4App("icons")); err != nil { + return err + } if _, err := prepareOmarchyV4ThemeDir(outputPath, state); err != nil { return err } - w.processOmarchyV4Templates(outputPath, variables, settings, state.AppOverrides, state.ExtendedColors) - return nil + return w.processOmarchyV4TemplatesWithIconTheme( + outputPath, + variables, + settings, + state.AppOverrides, + state.ExtendedColors, + state.IconTheme, + ) } // ApplyTheme generates all theme files and applies the theme to the system. @@ -269,12 +331,22 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return &ApplyResult{Success: false, IsOmarchy: IsOmarchyInstalled(), ThemePath: platform.ThemeDir()}, err } + isOmarchy := IsOmarchyInstalled() + isOmarchyV4 := isOmarchy && IsOmarchyV4() + if isOmarchyV4 { + settings = settings.withLegacyOmarchyV4Icons() + } + iconThemeEnabled := settings.includesApp("icons") + if isOmarchyV4 { + iconThemeEnabled = settings.includesOmarchyV4App("icons") + } + if err := validateIconTheme(state.IconTheme, iconThemeEnabled); err != nil { + return &ApplyResult{Success: false, IsOmarchy: isOmarchy, ThemePath: platform.ThemeDir()}, err + } if err := RetireLegacyGTKStylesheets(); err != nil { log.Printf("Warning: legacy GTK stylesheet cleanup failed: %v", err) } - isOmarchy := IsOmarchyInstalled() - isOmarchyV4 := isOmarchy && IsOmarchyV4() themeDir := platform.ThemeDir() var wallpaperDest string @@ -295,7 +367,16 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, } if isOmarchyV4 { - w.processOmarchyV4Templates(themeDir, variables, settings, state.AppOverrides, state.ExtendedColors) + if err := w.processOmarchyV4TemplatesWithIconTheme( + themeDir, + variables, + settings, + state.AppOverrides, + state.ExtendedColors, + state.IconTheme, + ); err != nil { + return &ApplyResult{Success: false, IsOmarchy: isOmarchy, ThemePath: themeDir}, err + } w.applyEditorThemes( themeDir, variables, @@ -307,7 +388,16 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, log.Printf("Warning: custom app processing failed: %v", err) } } else { - w.processTemplates(variables, themeDir, settings, state.AppOverrides, state.ExtendedColors) + if err := w.processTemplatesWithIconTheme( + variables, + themeDir, + settings, + state.AppOverrides, + state.ExtendedColors, + state.IconTheme, + ); err != nil { + return &ApplyResult{Success: false, IsOmarchy: isOmarchy, ThemePath: themeDir}, err + } w.applyEditorThemes( themeDir, variables, @@ -344,6 +434,9 @@ func (w *Writer) GenerateOnly(state *ThemeState, settings Settings, outputPath s if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return err } + if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { + return err + } targetDir := outputPath if targetDir == "" { targetDir = platform.ThemeDir() @@ -356,7 +449,16 @@ func (w *Writer) GenerateOnly(state *ThemeState, settings Settings, outputPath s return err } - w.processTemplates(variables, targetDir, settings, state.AppOverrides, state.ExtendedColors) + if err := w.processTemplatesWithIconTheme( + variables, + targetDir, + settings, + state.AppOverrides, + state.ExtendedColors, + state.IconTheme, + ); err != nil { + return err + } // Generate VSCode extension into the export directory vscodeDir := filepath.Join(targetDir, "vscode-extension") @@ -409,10 +511,29 @@ func (w *Writer) processTemplates( appOverrides map[string]map[string]string, globalOverrides map[string]string, ) { + if err := w.processTemplatesWithIconTheme( + variables, + outputDir, + settings, + appOverrides, + globalOverrides, + icontheme.Automatic(), + ); err != nil { + log.Printf("Error processing templates: %v", err) + } +} + +func (w *Writer) processTemplatesWithIconTheme( + variables map[string]string, + outputDir string, + settings Settings, + appOverrides map[string]map[string]string, + globalOverrides map[string]string, + iconTheme icontheme.Selection, +) error { names, err := template.ListTemplates(w.templatesFS, w.templatesDir) if err != nil { - log.Printf("Error listing templates: %v", err) - return + return fmt.Errorf("list templates: %w", err) } for _, fileName := range names { @@ -423,7 +544,8 @@ func (w *Writer) processTemplates( outputPath := filepath.Join(outputDir, fileName) appName := getAppNameFromFileName(fileName) - if appName != "colors" && !settings.includesApp(appName) && len(appOverrides[appName]) == 0 { + if appName != "colors" && !settings.includesApp(appName) && + (appName == "icons" || len(appOverrides[appName]) == 0) { if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { log.Printf("Error removing stale template %s: %v", fileName, err) } @@ -439,9 +561,37 @@ func (w *Writer) processTemplates( } continue } + if fileName == "icons.theme" { + if err := w.writeIconTheme(outputPath, variables, appOverrides, globalOverrides, iconTheme); err != nil { + return err + } + continue + } w.processTemplate(fileName, outputPath, variables, appOverrides, globalOverrides) } + return nil +} + +func (w *Writer) writeIconTheme( + outputPath string, + variables map[string]string, + appOverrides map[string]map[string]string, + globalOverrides map[string]string, + selection icontheme.Selection, +) error { + normalized, err := icontheme.NormalizeSelection(selection) + if err != nil { + return fmt.Errorf("validate icon theme selection: %w", err) + } + if normalized.Mode == icontheme.SelectionExplicit { + if err := platform.WriteText(outputPath, normalized.ID+"\n"); err != nil { + return fmt.Errorf("write icons.theme: %w", err) + } + return nil + } + w.processTemplate("icons.theme", outputPath, variables, appOverrides, globalOverrides) + return nil } // processTemplate reads a single template, applies variable substitution diff --git a/internal/theme/writer_icon_theme_test.go b/internal/theme/writer_icon_theme_test.go new file mode 100644 index 00000000..6b83368a --- /dev/null +++ b/internal/theme/writer_icon_theme_test.go @@ -0,0 +1,185 @@ +package theme + +import ( + "os" + "path/filepath" + "testing" + + "aether/internal/icontheme" +) + +func TestGenerateIconThemeMatrix(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + generators := map[string]func(*ThemeState, Settings, string) error{ + "standalone": func(state *ThemeState, settings Settings, output string) error { + return NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOnly(state, settings, output) + }, + "omarchy v4": func(state *ThemeState, settings Settings, output string) error { + return NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOmarchyV4Only(state, settings, output) + }, + } + + tests := []struct { + name string + selection icontheme.Selection + enabled bool + want string + wantFile bool + }{ + { + name: "legacy zero is automatic", + selection: icontheme.Selection{}, + enabled: true, + want: "Yaru-red\n", + wantFile: true, + }, + { + name: "automatic retains Yaru rendering", + selection: icontheme.Automatic(), + enabled: true, + want: "Yaru-red\n", + wantFile: true, + }, + { + name: "explicit safe missing ID is exact", + selection: icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Missing-But-Safe", + }, + enabled: true, + want: "Missing-But-Safe\n", + wantFile: true, + }, + { + name: "disabled automatic is omitted", + selection: icontheme.Automatic(), + enabled: false, + }, + { + name: "disabled explicit is omitted", + selection: icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Papirus-Dark", + }, + enabled: false, + }, + } + + for generatorName, generate := range generators { + generate := generate + t.Run(generatorName, func(t *testing.T) { + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + state := NewThemeState() + state.SetColor(5, "#ff0000") + state.IconTheme = tt.selection + output := t.TempDir() + settings := Settings{IncludedApps: map[string]bool{"icons": tt.enabled}} + + if err := generate(state, settings, output); err != nil { + t.Fatalf("generate: %v", err) + } + + got, err := os.ReadFile(filepath.Join(output, "icons.theme")) + if !tt.wantFile { + if !os.IsNotExist(err) { + t.Fatalf("disabled icons.theme exists or read failed unexpectedly: %v", err) + } + return + } + if err != nil { + t.Fatalf("read icons.theme: %v", err) + } + if string(got) != tt.want { + t.Errorf("icons.theme = %q, want %q", got, tt.want) + } + }) + } + }) + } +} + +func TestGenerateOnlyRejectsUnsafeExplicitIconThemeWithoutOutput(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + state := NewThemeState() + state.IconTheme = icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "../escape"} + output := t.TempDir() + err := NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOnly( + state, + Settings{IncludedApps: map[string]bool{"icons": true}}, + output, + ) + if err == nil { + t.Fatal("GenerateOnly accepted an unsafe explicit icon theme") + } + entries, readErr := os.ReadDir(output) + if readErr != nil { + t.Fatalf("read output directory: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("GenerateOnly wrote %d entries before rejecting the icon theme", len(entries)) + } +} + +func TestGenerateOmarchyV4OnlyUsesLegacyDefaultIconTarget(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + state := NewThemeState() + state.SetColor(5, "#ff0000") + state.IconTheme = icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Missing-But-Safe", + } + output := t.TempDir() + if err := NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOmarchyV4Only( + state, + DefaultApplySettings(), + output, + ); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(filepath.Join(output, "icons.theme")) + if err != nil { + t.Fatalf("read icons.theme: %v", err) + } + if want := "Missing-But-Safe\n"; string(got) != want { + t.Errorf("icons.theme = %q, want %q", got, want) + } +} + +func TestDisabledIconTargetCannotBeReenabledByTemplateOverrides(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + generators := map[string]func(*ThemeState, Settings, string) error{ + "standalone": func(state *ThemeState, settings Settings, output string) error { + return NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOnly(state, settings, output) + }, + "omarchy v4": func(state *ThemeState, settings Settings, output string) error { + return NewWriter(omarchyV4TestTemplates, "testdata/v4").GenerateOmarchyV4Only(state, settings, output) + }, + } + + for name, generate := range generators { + t.Run(name, func(t *testing.T) { + state := NewThemeState() + state.IconTheme = icontheme.Selection{ + Mode: icontheme.SelectionExplicit, + ID: "Papirus-Dark", + } + state.AppOverrides["icons"] = map[string]string{"magenta": "#ff0000"} + output := t.TempDir() + settings := Settings{IncludedApps: map[string]bool{"icons": false}} + + if err := generate(state, settings, output); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(output, "icons.theme")); !os.IsNotExist(err) { + t.Fatalf("disabled icons.theme exists or stat failed unexpectedly: %v", err) + } + }) + } +}