diff --git a/README.md b/README.md index 5b7cdb5..ed68077 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 26d5449..a4b2301 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. @@ -229,12 +247,17 @@ type SyncStateRequest struct { NativeColors map[string]string `json:"nativeColors"` 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++ { @@ -256,6 +279,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 @@ -333,6 +358,7 @@ type ApplyThemeRequest struct { NativeColors map[string]string `json:"nativeColors"` 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. @@ -353,6 +379,7 @@ func (a *App) ApplyTheme(req ApplyThemeRequest) (*theme.ApplyResult, error) { NativeColors: req.NativeColors, AdditionalImages: req.AdditionalImages, AppOverrides: appOverrides, + IconTheme: req.IconTheme, } return a.writer.ApplyTheme(state, req.Settings) @@ -371,6 +398,7 @@ type SaveAndApplyThemeRequest struct { NativeColors map[string]string `json:"nativeColors"` 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. @@ -395,6 +423,7 @@ func (a *App) SaveAndApplyTheme(req SaveAndApplyThemeRequest) (*theme.ApplyResul NativeColors: req.NativeColors, AdditionalImages: req.AdditionalImages, AppOverrides: req.AppOverrides, + IconTheme: req.IconTheme, } if state.AppOverrides == nil { state.AppOverrides = make(map[string]map[string]string) @@ -459,6 +488,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, @@ -474,6 +507,7 @@ func (a *App) ListBlueprints() ([]map[string]interface{}, error) { }, "adjustments": bp.Adjustments, "appOverrides": bp.AppOverrides, + "iconTheme": iconTheme, } } return result, nil @@ -492,6 +526,7 @@ type SaveBlueprintRequest struct { NativeColors map[string]string `json:"nativeColors"` 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 { @@ -508,6 +543,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) } @@ -572,6 +610,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 } @@ -611,6 +654,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()) } @@ -984,6 +1032,7 @@ type ExportThemeRequest struct { NativeColors map[string]string `json:"nativeColors"` 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. @@ -1043,6 +1092,7 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) { NativeColors: req.NativeColors, AdditionalImages: req.AdditionalImages, AppOverrides: exportOverrides, + IconTheme: req.IconTheme, } // Build included set from the request @@ -1092,13 +1142,14 @@ 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"` - NativeColors map[string]string `json:"nativeColors"` - 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"` + NativeColors map[string]string `json:"nativeColors"` + 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. @@ -1184,6 +1235,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{ @@ -1194,6 +1250,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 } @@ -1348,6 +1405,7 @@ func (a *App) HandleIPC(req ipc.Request) ipc.Response { NativeColors: a.state.NativeColors, Settings: theme.DefaultApplySettings(), AppOverrides: a.state.AppOverrides, + IconTheme: a.state.IconTheme, }) if err != nil { return ipc.Response{OK: false, Error: err.Error()} @@ -1442,6 +1500,7 @@ func (a *App) emitIPCStateChanged() { "appOverrides": a.state.AppOverrides, "additionalImages": a.state.AdditionalImages, "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 0000000..6dab4bb --- /dev/null +++ b/app_icon_theme_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "aether/internal/blueprint" + "aether/internal/icontheme" + "aether/internal/pending" + "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 TestStageExternalBlueprintPreservesExplicitIconTheme(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(), "external.json") + data := fmt.Sprintf(`{"name":"External","palette":{"colors":%s},"iconTheme":{"mode":"explicit","id":"Missing-But-Safe"}}`, validAppTestPaletteJSON()) + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + app := NewApp() + app.pending.curr = &pending.Import{SourceURL: "aether://test", ExternalTheme: path} + if _, err := app.stageImportIntoState("aether://test"); err != nil { + t.Fatal(err) + } + want := icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "Missing-But-Safe"} + if app.state.IconTheme != want { + t.Fatalf("icon theme = %+v, want %+v", app.state.IconTheme, want) + } +} + +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 c6e5e6d..9dea93e 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, @@ -135,6 +147,7 @@ func runApplyBlueprint(args []string, templatesFS embed.FS) int { NativeColors: bp.Palette.NativeColors, 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 2469364..968b2ed 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 87a14b1..d8d401d 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 07d3b48..a030b92 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 bdd9b20..217fd58 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 0000000..e2df620 --- /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 ce387ac..bbb89c8 100644 --- a/cli/imports.go +++ b/cli/imports.go @@ -54,6 +54,11 @@ func applyImportedTheme(templatesFS embed.FS, bp *blueprint.Blueprint, palette [ state.NativeColors[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 8246809..5d9972d 100644 --- a/cli/url_handler.go +++ b/cli/url_handler.go @@ -222,6 +222,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 081753e..725c2e0 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 bec278b..378f540 100644 --- a/external_import.go +++ b/external_import.go @@ -166,6 +166,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/frontend/src/App.svelte b/frontend/src/App.svelte index 125cd8e..6a782da 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -58,6 +58,7 @@ setPalette, setExtendedColors, setNativeColors, + setIconTheme, setAdjustments, setColor, setExtendedColor, @@ -268,6 +269,7 @@ if (s?.nativeColors) { setNativeColors(s.nativeColors); } + setIconTheme(s?.iconTheme, true); if (s?.wallpaperPath) { setWallpaperPath(s.wallpaperPath); // Treat the restored wallpaper as already-extracted so a @@ -496,6 +498,7 @@ palette?: string[]; extendedColors?: Record; nativeColors?: Record; + iconTheme?: {mode?: string; id?: string}; lightMode?: boolean; mode?: string; wallpaper?: string; @@ -512,6 +515,8 @@ if (state.nativeColors) { setNativeColors(state.nativeColors); } + if (state.iconTheme) + setIconTheme(state.iconTheme, true); if (state.lightMode !== undefined) { setLightMode(state.lightMode); } diff --git a/frontend/src/lib/actions/blueprintActions.ts b/frontend/src/lib/actions/blueprintActions.ts index ab8ab04..7ba44f3 100644 --- a/frontend/src/lib/actions/blueprintActions.ts +++ b/frontend/src/lib/actions/blueprintActions.ts @@ -4,6 +4,7 @@ import { setPalette, setExtendedColors, setNativeColors, + setIconTheme, setLightMode, setWallpaperPath, setAppOverrides, @@ -27,6 +28,7 @@ export function loadBlueprintIntoEditor(bp: Blueprint): void { setPalette(colors); setExtendedColors(bp.palette.extendedColors ?? {}); setNativeColors(bp.palette.nativeColors ?? {}); + setIconTheme(bp.iconTheme, true); setLightMode( bp.palette.mode ? bp.palette.mode === 'light' : !!bp.palette.lightMode ); diff --git a/frontend/src/lib/components/blueprints/OmarchyThemes.svelte b/frontend/src/lib/components/blueprints/OmarchyThemes.svelte index bd789a4..dd61a2d 100644 --- a/frontend/src/lib/components/blueprints/OmarchyThemes.svelte +++ b/frontend/src/lib/components/blueprints/OmarchyThemes.svelte @@ -4,6 +4,7 @@ setPalette, setExtendedColors, setNativeColors, + setIconTheme, setWallpaperPath, setAdditionalImages, setAppOverrides, @@ -63,6 +64,7 @@ setPalette(theme.colors); setExtendedColors(theme.extendedColors ?? {}); setNativeColors(theme.nativeColors ?? {}); + setIconTheme(theme.iconTheme, true); if (theme.mode) setLightMode(theme.mode === 'light'); setWallpaperPath(theme.wallpapers?.[0] ?? ''); setAdditionalImages(theme.wallpapers?.slice(1) ?? []); diff --git a/frontend/src/lib/components/blueprints/SaveDialog.svelte b/frontend/src/lib/components/blueprints/SaveDialog.svelte index b2390f3..b64423b 100644 --- a/frontend/src/lib/components/blueprints/SaveDialog.svelte +++ b/frontend/src/lib/components/blueprints/SaveDialog.svelte @@ -9,6 +9,7 @@ getAdditionalImages, getExtendedColors, getNativeColors, + getIconTheme, getAppOverrides, getAdjustments, } from '$lib/stores/theme.svelte'; @@ -80,6 +81,7 @@ lockedColors: [], extendedColors: {...getExtendedColors()}, nativeColors: {...getNativeColors()}, + iconTheme: {...getIconTheme()}, appOverrides: Object.fromEntries( Object.entries(getAppOverrides()).map(([app, colors]) => [ app, @@ -87,7 +89,7 @@ ]) ), adjustments: {...getAdjustments()}, - }; + } as unknown as main.SaveBlueprintRequest; pendingSave = request; try { const {BlueprintExists} = await import( diff --git a/frontend/src/lib/components/layout/ActionBar.svelte b/frontend/src/lib/components/layout/ActionBar.svelte index ec7706a..00f6c78 100644 --- a/frontend/src/lib/components/layout/ActionBar.svelte +++ b/frontend/src/lib/components/layout/ActionBar.svelte @@ -1,4 +1,5 @@ + +
+ 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 0000000..34e63ee --- /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 c62f609..4115604 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'; import { @@ -40,6 +41,10 @@ > + +
+ +
diff --git a/frontend/src/lib/components/sidebar/TemplateToggles.svelte b/frontend/src/lib/components/sidebar/TemplateToggles.svelte index 9ae0570..3c49c83 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 d2a4474..3e09453 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; @@ -20,6 +20,7 @@ export interface Snapshot { extendedColors: Record; baseExtendedColors: Record; appOverrides: Record>; + iconTheme: IconThemeSelection; adjustments: Adjustments; paletteCurvePoints: [number, number][]; extractionMode: string; @@ -60,6 +61,7 @@ export function copySnapshot(snapshot: Snapshot): Snapshot { adjustments: {...snapshot.adjustments}, paletteCurvePoints: snapshot.paletteCurvePoints.map(([x, y]) => [x, y]), extractionMode: snapshot.extractionMode, + iconTheme: {...snapshot.iconTheme}, pendingAdjustment: pending ? { previousAdjustments: {...pending.previousAdjustments}, diff --git a/frontend/src/lib/stores/settings.svelte.ts b/frontend/src/lib/stores/settings.svelte.ts index 5f39871..0a997bc 100644 --- a/frontend/src/lib/stores/settings.svelte.ts +++ b/frontend/src/lib/stores/settings.svelte.ts @@ -72,13 +72,14 @@ export function updateSettings(partial: Partial): void { } export function isAppIncluded(app: string): boolean { + if (app === 'icons') return settings.includedApps?.icons !== false; return !!settings.includedApps?.[app]; } export function setAppIncluded(app: string, enabled: boolean): void { const current = {...(settings.includedApps ?? {})}; - if (enabled) { - current[app] = true; + if (enabled || app === 'icons') { + current[app] = enabled; } else { delete current[app]; } diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index db6af8a..6a91ec6 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -1,6 +1,9 @@ import { DEFAULT_PALETTE, DEFAULT_ADJUSTMENTS, + AUTOMATIC_ICON_THEME, + normalizeIconThemeSelection, + type IconThemeSelection, type Adjustments, type ColorRoles, } from '$lib/types/theme'; @@ -42,6 +45,7 @@ let isApplying = $state(false); let additionalImages = $state([]); let appOverrides = $state>>({}); let nativeColors = $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 @@ -167,6 +171,19 @@ export function getExtendedColors(): Record { export function getNativeColors(): Record { return nativeColors; } +export function getIconTheme(): IconThemeSelection { + return iconTheme; +} +export function setIconTheme( + value: {mode?: string; id?: string} | null | undefined, + skipHistory = false +): void { + const next = normalizeIconThemeSelection(value); + if (iconTheme.mode === next.mode && iconTheme.id === next.id) return; + endColorEditSessions(); + if (!skipHistory) pushState(getHistorySnapshot()); + iconTheme = next; +} export function getBaseExtendedColors(): Record { return baseExtendedColors; } @@ -185,6 +202,7 @@ export function getHistorySnapshot(): Snapshot { paletteCurvePoints, extractionMode, pendingAdjustment, + iconTheme, }); } @@ -197,6 +215,7 @@ export function restoreHistorySnapshot(snapshot: Snapshot): void { extendedColors = restored.extendedColors; baseExtendedColors = restored.baseExtendedColors; appOverrides = restored.appOverrides; + iconTheme = restored.iconTheme; adjustments = restored.adjustments; paletteCurvePoints = restored.paletteCurvePoints; extractionMode = restored.extractionMode; @@ -330,6 +349,7 @@ export function getThemeSnapshot(): { nativeColors: Record; appOverrides: Record>; additionalImages: string[]; + iconTheme: IconThemeSelection; } { return { palette: [...palette], @@ -344,6 +364,7 @@ export function getThemeSnapshot(): { ]) ), additionalImages: [...additionalImages], + iconTheme: {...iconTheme}, }; } @@ -358,6 +379,7 @@ export function getThemeSignature(snapshot = getThemeSnapshot()): string { snapshot.nativeColors, snapshot.appOverrides, snapshot.additionalImages, + snapshot.iconTheme, ]); } @@ -687,6 +709,7 @@ export function reset(): void { baseExtendedColors = {...ext}; appOverrides = {}; nativeColors = {}; + iconTheme = {...AUTOMATIC_ICON_THEME}; paletteCurvePoints = []; lastExtractedPath = ''; } diff --git a/frontend/src/lib/types/theme.ts b/frontend/src/lib/types/theme.ts index 53975c2..a83209d 100644 --- a/frontend/src/lib/types/theme.ts +++ b/frontend/src/lib/types/theme.ts @@ -38,6 +38,25 @@ 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' && + typeof value.id === 'string' && + 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 { @@ -59,6 +78,7 @@ export interface Blueprint { adjustments?: Record; appOverrides?: Record>; settings?: Record; + iconTheme?: IconThemeSelection; timestamp: number; path?: string; filename?: string; diff --git a/frontend/tests/apply-actions.test.ts b/frontend/tests/apply-actions.test.ts index 41db2ca..71f44c1 100644 --- a/frontend/tests/apply-actions.test.ts +++ b/frontend/tests/apply-actions.test.ts @@ -53,6 +53,7 @@ test.each(['apply', 'save'] as const)( theme.setAdditionalImages(['/extra.png']); theme.setExtendedColor('accent', '#123456'); theme.setNativeColors({outline: '#112233'}); + theme.setIconTheme({mode: 'explicit', id: 'Original-Icons'}, true); theme.setAppOverride('kitty', 'background', '#123456'); updateSettings({includedApps: {kitty: true}}); const original = theme.getThemeSnapshot(); @@ -76,6 +77,7 @@ test.each(['apply', 'save'] as const)( theme.setAdditionalImages(['/new-extra.png']); theme.setExtendedColor('accent', '#ffffff'); theme.setNativeColors({outline: '#ffffff'}); + theme.setIconTheme({mode: 'explicit', id: 'Later-Icons'}, true); theme.setAppOverride('kitty', 'background', '#ffffff'); getSettings().includedApps!.kitty = false; preflight.resolve(); diff --git a/frontend/tests/command-palette.test.ts b/frontend/tests/command-palette.test.ts index 1caad6a..6215340 100644 --- a/frontend/tests/command-palette.test.ts +++ b/frontend/tests/command-palette.test.ts @@ -27,6 +27,7 @@ const blueprint = { }, adjustments: {brightness: 25}, appOverrides: {kitty: {background: '#123456'}}, + iconTheme: {mode: 'explicit' as const, id: 'Forest-Icons'}, }; beforeEach(() => { @@ -95,6 +96,7 @@ test('keyboard search loads a saved blueprint into the editor without applying i expect(theme.getExtendedColors().accent).toBe('#abcdef'); expect(theme.getNativeColors()).toEqual(blueprint.palette.nativeColors); expect(theme.getAppOverrides()).toEqual(blueprint.appOverrides); + expect(theme.getIconTheme()).toEqual(blueprint.iconTheme); expect(theme.getWallpaperPath()).toBe('/forest.png'); expect(theme.getLockedColors()[1]).toBe(true); expect(theme.getLockedColors()[2]).toBe(false); diff --git a/frontend/tests/icon-theme.test.ts b/frontend/tests/icon-theme.test.ts new file mode 100644 index 0000000..26966b6 --- /dev/null +++ b/frontend/tests/icon-theme.test.ts @@ -0,0 +1,210 @@ +import {beforeEach, expect, test, vi} from 'vitest'; +import IconThemePicker from '../src/lib/components/sidebar/IconThemePicker.svelte'; +import OmarchyThemes from '../src/lib/components/blueprints/OmarchyThemes.svelte'; +import * as theme from '../src/lib/stores/theme.svelte'; +import {isAppIncluded, setAppIncluded} from '../src/lib/stores/settings.svelte'; +import { + normalizeIconThemeSelection, + DEFAULT_PALETTE, +} from '../src/lib/types/theme'; +import {undoAction, redoAction} from '../src/lib/actions/themeActions'; +import {loadBlueprintIntoEditor} from '../src/lib/actions/blueprintActions'; +import { + GetIconThemePreview, + ListInstalledIconThemes, + RefreshInstalledIconThemes, + LoadOmarchyThemes, +} from '../wailsjs/go/main/App'; +import {render, settle} from './setup'; + +vi.mock('../wailsjs/go/main/App', () => ({ + GetSettings: vi.fn().mockResolvedValue({}), + SaveSettings: vi.fn().mockResolvedValue(undefined), + ListInstalledIconThemes: vi.fn(), + RefreshInstalledIconThemes: vi.fn(), + GetIconThemePreview: vi.fn().mockResolvedValue({samples: []}), + LoadOmarchyThemes: vi.fn().mockResolvedValue([]), +})); +vi.mock('../src/lib/stores/omarchy.svelte', () => ({ + getOmarchyAvailable: () => true, + getOmarchyCapabilities: () => ({overrideApps: ['kitty']}), + initOmarchyCapabilities: vi.fn().mockResolvedValue(undefined), + refreshOmarchyCapabilities: vi.fn().mockResolvedValue(undefined), +})); + +const ocean = {id: 'Ocean', name: 'Ocean', origin: 'user', hasPreview: true}; + +beforeEach(async () => { + await settle(); + theme.reset(); + theme.markApplied(); + setAppIncluded('icons', true); + vi.mocked(ListInstalledIconThemes).mockReset().mockResolvedValue([ocean]); + vi.mocked(RefreshInstalledIconThemes) + .mockReset() + .mockResolvedValue([ocean]); + vi.mocked(GetIconThemePreview) + .mockReset() + .mockResolvedValue({themeId: 'Ocean', samples: []}); + vi.mocked(LoadOmarchyThemes).mockReset().mockResolvedValue([]); + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor( + private callback: (entries: {isIntersecting: boolean}[]) => void + ) {} + observe() { + this.callback([{isIntersecting: true}]); + } + disconnect() {} + } + ); +}); + +test('legacy selection defaults to automatic and missing explicit IDs survive', () => { + expect(normalizeIconThemeSelection(undefined)).toEqual({mode: 'automatic'}); + expect(normalizeIconThemeSelection({mode: 'automatic'})).toEqual({ + mode: 'automatic', + }); + expect( + normalizeIconThemeSelection({mode: 'explicit', id: 'Missing-Icons'}) + ).toEqual({mode: 'explicit', id: 'Missing-Icons'}); +}); + +test('icon undo and redo preserve adjusted colors and their original baselines', () => { + theme.setPalette(Array(16).fill('#123456'), true); + theme.setAdjustedPalette(Array(16).fill('#456789')); + theme.markApplied(); + const before = theme.getHistorySnapshot(); + theme.setIconTheme({mode: 'explicit', id: 'Ocean'}); + expect(theme.isDirty()).toBe(true); + undoAction(); + expect(theme.getHistorySnapshot()).toEqual(before); + expect(theme.isDirty()).toBe(false); + redoAction(); + expect(theme.getBasePalette()).toEqual(before.basePalette); + expect(theme.getPalette()).toEqual(before.palette); + expect(theme.getIconTheme()).toEqual({mode: 'explicit', id: 'Ocean'}); +}); + +test('blueprint loads restore the icon selection and clear it for legacy blueprints', () => { + const blueprint = { + name: 'Saved', + timestamp: 0, + palette: {colors: [...DEFAULT_PALETTE]}, + }; + loadBlueprintIntoEditor({ + ...blueprint, + iconTheme: {mode: 'explicit', id: 'Missing-Icons'}, + }); + expect(theme.getIconTheme()).toEqual({ + mode: 'explicit', + id: 'Missing-Icons', + }); + loadBlueprintIntoEditor(blueprint); + expect(theme.getIconTheme()).toEqual({mode: 'automatic'}); +}); + +test('extraction preserves the icon choice and reset restores automatic', () => { + theme.setIconTheme({mode: 'explicit', id: 'Ocean'}, true); + theme.setPaletteFromExtraction('/new.png', [...DEFAULT_PALETTE]); + expect(theme.getIconTheme()).toEqual({mode: 'explicit', id: 'Ocean'}); + theme.reset(); + expect(theme.getIconTheme()).toEqual({mode: 'automatic'}); +}); + +test('an Omarchy theme import replaces the previous icon selection', async () => { + theme.setIconTheme({mode: 'explicit', id: 'Previous'}, true); + vi.mocked(LoadOmarchyThemes).mockResolvedValue([ + { + name: 'Native', + colors: [...DEFAULT_PALETTE], + wallpapers: [], + iconTheme: {mode: 'explicit', id: 'Native-Icons'}, + }, + ]); + const {target} = render(OmarchyThemes, {}); + await settle(); + [...target.querySelectorAll('button')] + .find(button => button.textContent?.trim() === 'Edit')! + .click(); + expect(theme.getIconTheme()).toEqual({ + mode: 'explicit', + id: 'Native-Icons', + }); +}); + +test('the chooser selects an installed theme and preserves it while Icons is disabled', async () => { + const {target} = render(IconThemePicker, {}); + await settle(); + target + .querySelector( + 'button[aria-label^="Choose icon theme"]' + )! + .click(); + await settle(); + const option = target.querySelector( + 'button[aria-label="Use icon theme Ocean"]' + )!; + expect(option.getAttribute('aria-pressed')).toBe('false'); + option.focus(); + option.click(); + await settle(); + expect(theme.getIconTheme()).toEqual({mode: 'explicit', id: 'Ocean'}); + expect(target.querySelector('[role="dialog"]')).toBeNull(); + target + .querySelector('[aria-label="Toggle Icons"]')! + .click(); + await settle(); + expect(isAppIncluded('icons')).toBe(false); + expect( + target.querySelector( + 'button[aria-label^="Choose icon theme"]' + )!.disabled + ).toBe(true); + expect(theme.getIconTheme()).toEqual({mode: 'explicit', id: 'Ocean'}); +}); + +test('catalog refresh reloads previews for unchanged theme IDs', async () => { + const {target} = render(IconThemePicker, {}); + await settle(); + target + .querySelector( + 'button[aria-label^="Choose icon theme"]' + )! + .click(); + await settle(); + expect(GetIconThemePreview).toHaveBeenCalledTimes(1); + [...target.querySelectorAll('button')] + .find(button => button.textContent?.trim() === 'Refresh')! + .click(); + await settle(); + expect(GetIconThemePreview).toHaveBeenCalledTimes(2); +}); + +test('a failed catalog refresh preserves the explicit selection', async () => { + theme.setIconTheme({mode: 'explicit', id: 'Missing-Icons'}, true); + const {target} = render(IconThemePicker, {}); + await settle(); + target + .querySelector( + 'button[aria-label^="Choose icon theme"]' + )! + .click(); + await settle(); + expect(target.textContent).toContain('Missing-Icons'); + vi.mocked(RefreshInstalledIconThemes).mockRejectedValueOnce( + new Error('unavailable') + ); + [...target.querySelectorAll('button')] + .find(button => button.textContent?.trim() === 'Refresh')! + .click(); + await settle(); + expect(target.querySelector('[role="status"]')?.textContent).toContain( + 'Could not refresh' + ); + expect(theme.getIconTheme()).toEqual({ + mode: 'explicit', + id: 'Missing-Icons', + }); +}); diff --git a/frontend/tests/save-dialog.test.ts b/frontend/tests/save-dialog.test.ts index c57cea4..2ee82d2 100644 --- a/frontend/tests/save-dialog.test.ts +++ b/frontend/tests/save-dialog.test.ts @@ -38,6 +38,7 @@ test('repeated Enter cannot bypass overwrite confirmation, and Override saves th vi.mocked(SaveBlueprint).mockReturnValue(saved.promise); theme.setWallpaperPath('/original.png'); theme.setAppOverride('kitty', 'background', '#123456'); + theme.setIconTheme({mode: 'explicit', id: 'Original-Icons'}, true); const originalPalette = [...theme.getPalette()]; const onsave = vi.fn(); const {target} = render(SaveDialog, {open: true, onclose: vi.fn(), onsave}); @@ -54,6 +55,7 @@ test('repeated Enter cannot bypass overwrite confirmation, and Override saves th theme.setColor(0, '#abcdef'); theme.setWallpaperPath('/different.png'); theme.setAppOverride('kitty', 'background', '#ffffff'); + theme.setIconTheme({mode: 'explicit', id: 'Later-Icons'}, true); exists.resolve(true); await settle(); expect(target.textContent).toContain('A theme named "Original"'); @@ -71,6 +73,7 @@ test('repeated Enter cannot bypass overwrite confirmation, and Override saves th palette: originalPalette, wallpaperPath: '/original.png', appOverrides: {kitty: {background: '#123456'}}, + iconTheme: {mode: 'explicit', id: 'Original-Icons'}, }) ); expect(onsave).not.toHaveBeenCalled(); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index aee4204..19ae1cc 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 {omarchy} from '../models'; import {ipc} from '../models'; import {wallpaper} from '../models'; @@ -70,6 +71,10 @@ export function GetFavorites(): Promise>; export function GetFocusTab(): Promise; +export function GetIconThemePreview( + arg1: string +): Promise; + export function GetInitialState(): Promise; export function GetOmarchyCapabilities(): Promise; @@ -108,6 +113,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>; @@ -124,6 +133,10 @@ export function PreviewExtractColors( export function ReadImageAsDataURL(arg1: string): Promise; +export function RefreshInstalledIconThemes(): Promise< + Array +>; + export function ResetState(): Promise; export function SaveAndApplyTheme( diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c5ce5df..f44f844 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -94,6 +94,10 @@ export function GetFocusTab() { return window['go']['main']['App']['GetFocusTab'](); } +export function GetIconThemePreview(arg1) { + return window['go']['main']['App']['GetIconThemePreview'](arg1); +} + export function GetInitialState() { return window['go']['main']['App']['GetInitialState'](); } @@ -170,6 +174,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); } @@ -198,6 +206,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 b99d8c5..25bcf65 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; @@ -141,6 +226,7 @@ export namespace main { nativeColors: Record; settings: theme.Settings; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ApplyThemeRequest(source); @@ -159,6 +245,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 { @@ -192,6 +282,7 @@ export namespace main { nativeColors: Record; installToOmarchy: boolean; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ExportThemeRequest(source); @@ -209,6 +300,30 @@ export namespace main { this.nativeColors = source['nativeColors']; 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 { @@ -263,6 +378,7 @@ export namespace main { path: string; wallpaperPath: string; lightMode: boolean; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new ImportResult(source); @@ -277,6 +393,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 { @@ -290,6 +430,7 @@ export namespace main { nativeColors: Record; settings: theme.Settings; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SaveAndApplyThemeRequest(source); @@ -310,6 +451,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 { @@ -343,6 +488,7 @@ export namespace main { nativeColors: Record; appOverrides: Record; adjustments: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SaveBlueprintRequest(source); @@ -360,6 +506,30 @@ export namespace main { this.nativeColors = source['nativeColors']; 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 { @@ -370,6 +540,7 @@ export namespace main { nativeColors: Record; appOverrides: Record; additionalImages: string[]; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new SyncStateRequest(source); @@ -384,6 +555,30 @@ export namespace main { this.nativeColors = source['nativeColors']; 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; } } } @@ -418,6 +613,7 @@ export namespace omarchy { colors: string[]; extendedColors: Record; nativeColors: Record; + iconTheme: icontheme.Selection; background: string; foreground: string; mode: string; @@ -442,6 +638,10 @@ export namespace omarchy { this.colors = source['colors']; this.extendedColors = source['extendedColors']; this.nativeColors = source['nativeColors']; + this.iconTheme = this.convertValues( + source['iconTheme'], + icontheme.Selection + ); this.background = source['background']; this.foreground = source['foreground']; this.mode = source['mode']; @@ -454,6 +654,26 @@ export namespace omarchy { this.isCurrentTheme = source['isCurrentTheme']; this.isAetherGenerated = source['isAetherGenerated']; } + + 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; + } } } @@ -564,6 +784,7 @@ export namespace theme { extractionMode: string; additionalImages: string[]; appOverrides: Record; + iconTheme: icontheme.Selection; static createFrom(source: any = {}) { return new StateSnapshot(source); @@ -584,6 +805,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 0000000..8a25081 --- /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 fcbe5c3..d177533 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 0a47713..decdfaf 100644 --- a/internal/blueprint/validate.go +++ b/internal/blueprint/validate.go @@ -39,5 +39,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 0000000..b74fa5c --- /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 0000000..51b1d86 --- /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 0000000..e617320 --- /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 0000000..8bc1538 --- /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 0000000..52d403f --- /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 0000000..c26fea6 --- /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 0000000..a55d4f5 --- /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 0000000..a75b738 --- /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/omarchy/icon_theme_test.go b/internal/omarchy/icon_theme_test.go new file mode 100644 index 0000000..ddff2fa --- /dev/null +++ b/internal/omarchy/icon_theme_test.go @@ -0,0 +1,43 @@ +package omarchy + +import ( + "os" + "path/filepath" + "testing" + + "aether/internal/icontheme" +) + +func TestThemeDiscoveryPreservesIconOverlaySelection(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("OMARCHY_PATH", t.TempDir()) + user, stock := t.TempDir(), t.TempDir() + t.Setenv(extraThemeDirsEnv, user+string(os.PathListSeparator)+stock) + for _, root := range []string{user, stock} { + if err := os.MkdirAll(filepath.Join(root, "sample"), 0o700); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(user, "sample", "icons.theme") + if err := os.WriteFile(path, []byte("User-Icons\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stock, "sample", "icons.theme"), []byte("Stock-Icons\n"), 0o600); err != nil { + t.Fatal(err) + } + themes, err := LoadAllThemes() + if err != nil || len(themes) != 1 { + t.Fatalf("themes = %+v, error = %v", themes, err) + } + if got := themes[0].IconTheme; got != (icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: "User-Icons"}) { + t.Fatalf("icon selection = %+v", got) + } + if err := os.WriteFile(path, []byte("../invalid\n"), 0o600); err != nil { + t.Fatal(err) + } + themes, err = LoadAllThemes() + if err != nil || len(themes) != 1 || themes[0].IconTheme != icontheme.Automatic() { + t.Fatalf("invalid selection does not default to automatic: %+v, %v", themes, err) + } +} diff --git a/internal/omarchy/themes.go b/internal/omarchy/themes.go index afa90a0..532566f 100644 --- a/internal/omarchy/themes.go +++ b/internal/omarchy/themes.go @@ -1,11 +1,14 @@ package omarchy import ( + "io" "os" "path/filepath" "regexp" "sort" "strings" + + "aether/internal/icontheme" ) // slugInvalid matches anything that isn't a lowercase letter, digit, hyphen, @@ -29,23 +32,24 @@ func SlugifyThemeName(name string) string { // Theme represents a discovered Omarchy theme. type Theme struct { - Name string `json:"name"` - Path string `json:"path"` - Sources []string `json:"sources"` - Colors []string `json:"colors"` - ExtendedColors map[string]string `json:"extendedColors"` - NativeColors map[string]string `json:"nativeColors"` - Background string `json:"background"` - Foreground string `json:"foreground"` - Mode string `json:"mode"` - Preview string `json:"preview"` - Wallpapers []string `json:"wallpapers"` - IsSymlink bool `json:"isSymlink"` - IsOverlay bool `json:"isOverlay"` - IsUserTheme bool `json:"isUserTheme"` - CanApply bool `json:"canApply"` - IsCurrentTheme bool `json:"isCurrentTheme"` - IsAetherGenerated bool `json:"isAetherGenerated"` + Name string `json:"name"` + Path string `json:"path"` + Sources []string `json:"sources"` + Colors []string `json:"colors"` + ExtendedColors map[string]string `json:"extendedColors"` + NativeColors map[string]string `json:"nativeColors"` + IconTheme icontheme.Selection `json:"iconTheme"` + Background string `json:"background"` + Foreground string `json:"foreground"` + Mode string `json:"mode"` + Preview string `json:"preview"` + Wallpapers []string `json:"wallpapers"` + IsSymlink bool `json:"isSymlink"` + IsOverlay bool `json:"isOverlay"` + IsUserTheme bool `json:"isUserTheme"` + CanApply bool `json:"canApply"` + IsCurrentTheme bool `json:"isCurrentTheme"` + IsAetherGenerated bool `json:"isAetherGenerated"` } // AETHER_EXTRA_THEME_DIRS is a colon-separated list of additional @@ -200,6 +204,7 @@ func LoadAllThemes() ([]Theme, error) { IsUserTheme: filepath.Clean(filepath.Dir(primary)) == userRoot, IsCurrentTheme: name == currentName, IsAetherGenerated: IsManagedThemeDir(primary), + IconTheme: readThemeIconSelection(sources), } theme.CanApply = len(sources) > 0 for _, source := range sources { @@ -237,6 +242,27 @@ func LoadAllThemes() ([]Theme, error) { return themes, nil } +func readThemeIconSelection(sources []string) icontheme.Selection { + file, err := os.Open(firstThemeFile(sources, "icons.theme")) + if err != nil { + return icontheme.Automatic() + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return icontheme.Automatic() + } + data, err := io.ReadAll(io.LimitReader(file, icontheme.MaxIDBytes+3)) + if err != nil || len(data) > icontheme.MaxIDBytes+2 { + return icontheme.Automatic() + } + id := strings.TrimSpace(string(data)) + if icontheme.ValidateID(id) != nil { + return icontheme.Automatic() + } + return icontheme.Selection{Mode: icontheme.SelectionExplicit, ID: id} +} + // TokyoNightDefaults loads the tokyo-night palette and its first // wallpaper (the "0-" file, by omarchy's naming convention) from a // local omarchy install. Returns ok=false on standalone systems where diff --git a/internal/theme/history_test.go b/internal/theme/history_test.go new file mode 100644 index 0000000..3f61eea --- /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/icon_theme.go b/internal/theme/icon_theme.go new file mode 100644 index 0000000..0be3676 --- /dev/null +++ b/internal/theme/icon_theme.go @@ -0,0 +1,36 @@ +package theme + +import ( + "fmt" + + "aether/internal/icontheme" + "aether/internal/platform" +) + +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 +} + +func (w *Writer) writeIconTheme(outputPath string, variables map[string]string, appOverrides map[string]map[string]string, globalOverrides map[string]string, selections ...icontheme.Selection) error { + selection := icontheme.Automatic() + if len(selections) > 0 { + selection = selections[0] + } + normalized, err := icontheme.NormalizeSelection(selection) + if err != nil { + return fmt.Errorf("icon theme: %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 + } + return w.processTemplate("icons.theme", outputPath, variables, appOverrides, globalOverrides) +} diff --git a/internal/theme/state.go b/internal/theme/state.go index 81d15f5..d0d0a40 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" ) @@ -19,6 +20,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. @@ -53,6 +55,7 @@ func NewThemeState() *ThemeState { ExtractionMode: "auto", AdditionalImages: []string{}, AppOverrides: make(map[string]map[string]string), + IconTheme: icontheme.Automatic(), } s.ColorRoles = s.buildColorRoles() return s @@ -94,6 +97,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. @@ -135,6 +139,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 44ea888..d8753e9 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 716907b..f72416d 100644 --- a/internal/theme/writer.go +++ b/internal/theme/writer.go @@ -12,6 +12,7 @@ import ( "strings" "aether/internal/color" + "aether/internal/icontheme" "aether/internal/omarchy" "aether/internal/platform" "aether/internal/template" @@ -89,6 +90,12 @@ type Settings struct { // includesApp supports explicit opt-in targets while preserving callers that // still use the legacy special flags and exclusion map. func (s Settings) includesApp(app string) bool { + if app == "icons" { + if enabled, ok := s.IncludedApps[app]; ok { + return enabled + } + return !s.ExcludedApps[app] + } if s.IncludedApps != nil { return s.IncludedApps[app] } @@ -247,6 +254,7 @@ func (w *Writer) processOmarchyV4Templates( settings Settings, appOverrides map[string]map[string]string, globalOverrides map[string]string, + iconThemes ...icontheme.Selection, ) error { if err := w.processTemplate( "colors.v4.toml", @@ -272,7 +280,15 @@ func (w *Writer) processOmarchyV4Templates( } appName := getAppNameFromFileName(fileName) - include := fileName == "icons.theme" || len(appOverrides[appName]) > 0 + if fileName == "icons.theme" { + if settings.includesApp("icons") { + if err := w.writeIconTheme(filepath.Join(themeDir, fileName), variables, appOverrides, globalOverrides, iconThemes...); err != nil { + return err + } + } + continue + } + include := len(appOverrides[appName]) > 0 if fileName == "neovim.lua" && settings.SelectedNeovimConfig != "" { include = true } @@ -307,6 +323,9 @@ func (w *Writer) GenerateOmarchyV4Only(state *ThemeState, settings Settings, out } func (w *Writer) generateOmarchyTheme(state *ThemeState, settings Settings, outputPath, activateName string) error { + if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { + return err + } variables := template.BuildVariables(state.ColorRoles, state.LightMode, state.ExtendedColors) if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return err @@ -341,7 +360,7 @@ func (w *Writer) generateOmarchyTheme(state *ThemeState, settings Settings, outp if err := preserveThemeMedia(outputPath, staging, state.WallpaperPath == "" && len(state.AdditionalImages) == 0); err != nil { return err } - if err := w.processOmarchyV4Templates(staging, variables, settings, state.AppOverrides, state.ExtendedColors); err != nil { + if err := w.processOmarchyV4Templates(staging, variables, settings, state.AppOverrides, state.ExtendedColors, state.IconTheme); err != nil { return err } if err := appendNativeColors(filepath.Join(staging, "colors.toml"), state.NativeColors); err != nil { @@ -370,6 +389,9 @@ func (w *Writer) generateOmarchyTheme(state *ThemeState, settings Settings, outp // ApplyTheme generates all theme files and applies the theme to the system. func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, error) { + if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { + return nil, err + } variables := template.BuildVariables(state.ColorRoles, state.LightMode, state.ExtendedColors) if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return &ApplyResult{Success: false, IsOmarchy: IsOmarchyInstalled(), ThemePath: platform.ThemeDir()}, err @@ -391,7 +413,7 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, if _, err := prepareThemeDir(themeDir, state); err != nil { return &ApplyResult{Success: false, ThemePath: themeDir}, err } - if err := w.processTemplates(variables, themeDir, settings, state.AppOverrides, state.ExtendedColors); err != nil { + if err := w.processTemplates(variables, themeDir, settings, state.AppOverrides, state.ExtendedColors, state.IconTheme); err != nil { return &ApplyResult{Success: false, ThemePath: themeDir}, err } if err := HandleLightModeMarker(themeDir, state.LightMode); err != nil { @@ -415,6 +437,9 @@ func (w *Writer) ApplyTheme(state *ThemeState, settings Settings) (*ApplyResult, // GenerateOnly generates theme files to the specified output path without // applying them (no symlinks, no service restarts, no omarchy activation). func (w *Writer) GenerateOnly(state *ThemeState, settings Settings, outputPath string) error { + if err := validateIconTheme(state.IconTheme, settings.includesApp("icons")); err != nil { + return err + } variables := template.BuildVariables(state.ColorRoles, state.LightMode, state.ExtendedColors) if err := validateTemplateInputs(variables, state.AppOverrides); err != nil { return err @@ -431,7 +456,7 @@ func (w *Writer) GenerateOnly(state *ThemeState, settings Settings, outputPath s return err } - if err := w.processTemplates(variables, targetDir, settings, state.AppOverrides, state.ExtendedColors); err != nil { + if err := w.processTemplates(variables, targetDir, settings, state.AppOverrides, state.ExtendedColors, state.IconTheme); err != nil { return err } @@ -695,6 +720,7 @@ func (w *Writer) processTemplates( settings Settings, appOverrides map[string]map[string]string, globalOverrides map[string]string, + iconThemes ...icontheme.Selection, ) error { names, err := template.ListTemplates(w.templatesFS, w.templatesDir) if err != nil { @@ -709,13 +735,20 @@ 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) { return fmt.Errorf("remove stale template %s: %w", fileName, err) } continue } + if fileName == "icons.theme" { + if err := w.writeIconTheme(outputPath, variables, appOverrides, globalOverrides, iconThemes...); err != nil { + return err + } + continue + } + // Handle neovim.lua with custom config selection if fileName == "neovim.lua" && settings.SelectedNeovimConfig != "" && len(appOverrides[appName]) == 0 { if err := platform.WriteText(outputPath, settings.SelectedNeovimConfig); err != nil { diff --git a/internal/theme/writer_icon_theme_test.go b/internal/theme/writer_icon_theme_test.go new file mode 100644 index 0000000..6b83368 --- /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) + } + }) + } +}