package matugen import ( "bytes" "encoding/json" "errors" "fmt" "math" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "syscall" "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/lucasb-eyer/go-colorful" ) var ErrNoChanges = errors.New("no color changes") type ColorMode string const ( ColorModeDark ColorMode = "dark" ColorModeLight ColorMode = "light" ) type TemplateKind int const ( TemplateKindNormal TemplateKind = iota TemplateKindTerminal TemplateKindGTK TemplateKindVSCode TemplateKindEmacs ) type TemplateDef struct { ID string Commands []string Flatpaks []string ConfigFile string Kind TemplateKind RunUnconditionally bool } var templateRegistry = []TemplateDef{ {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true}, {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"}, {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"}, {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"}, {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"}, {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"}, {ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"}, {ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"}, {ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"}, {ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"}, {ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"}, {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal}, {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal}, {ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal}, {ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal}, {ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal}, {ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal}, {ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"}, {ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true}, {ID: "vscode", Kind: TemplateKindVSCode}, {ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs}, {ID: "zed", Commands: []string{"zed", "zeditor", "zedit"}, ConfigFile: "zed.toml"}, } func (c *ColorMode) GTKTheme() string { switch *c { case ColorModeDark: return "adw-gtk3-dark" default: return "adw-gtk3" } } var ( matugenVersionMu sync.Mutex matugenVersionOK bool matugenSupportsCOE bool matugenIsV4 bool ) type Options struct { StateDir string ShellDir string ConfigDir string Kind string Value string Mode ColorMode IconTheme string MatugenType string Contrast float64 RunUserTemplates bool ColorsOnly bool StockColors string SyncModeWithPortal bool TerminalsAlwaysDark bool SkipTemplates string AppChecker utils.AppChecker } type ColorsOutput struct { Colors struct { Dark map[string]string `json:"dark"` Light map[string]string `json:"light"` } `json:"colors"` } func (o *Options) ColorsOutput() string { return filepath.Join(o.StateDir, "dms-colors.json") } func (o *Options) ShouldSkipTemplate(name string) bool { if o.SkipTemplates == "" { return false } for _, skip := range strings.Split(o.SkipTemplates, ",") { if strings.TrimSpace(skip) == name { return true } } return false } func Run(opts Options) error { if opts.StateDir == "" { return fmt.Errorf("state-dir is required") } if opts.ShellDir == "" { return fmt.Errorf("shell-dir is required") } if opts.ConfigDir == "" { return fmt.Errorf("config-dir is required") } if opts.Kind == "" { return fmt.Errorf("kind is required") } if opts.Value == "" { return fmt.Errorf("value is required") } if opts.Mode == "" { opts.Mode = ColorModeDark } if opts.MatugenType == "" { opts.MatugenType = "scheme-tonal-spot" } if opts.IconTheme == "" { opts.IconTheme = "System Default" } if opts.AppChecker == nil { opts.AppChecker = utils.DefaultAppChecker{} } if err := os.MkdirAll(opts.StateDir, 0o755); err != nil { return fmt.Errorf("failed to create state dir: %w", err) } log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode) changed, buildErr := buildOnce(&opts) if buildErr != nil { return buildErr } if !changed { log.Info("No color changes detected, skipping refresh") return ErrNoChanges } if opts.SyncModeWithPortal { syncColorScheme(opts.Mode) } log.Info("Done") return nil } func buildOnce(opts *Options) (bool, error) { cfgFile, err := os.CreateTemp("", "matugen-config-*.toml") if err != nil { return false, fmt.Errorf("failed to create temp config: %w", err) } defer os.Remove(cfgFile.Name()) defer cfgFile.Close() tmpDir, err := os.MkdirTemp("", "matugen-templates-*") if err != nil { return false, fmt.Errorf("failed to create temp dir: %w", err) } defer os.RemoveAll(tmpDir) if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil { return false, fmt.Errorf("failed to build config: %w", err) } cfgFile.Close() oldColors, _ := os.ReadFile(opts.ColorsOutput()) var primaryDark, primaryLight, surface string var dank16JSON string var importArgs []string if opts.StockColors != "" { log.Info("Using stock/custom theme colors with matugen base") primaryDark = extractNestedColor(opts.StockColors, "primary", "dark") primaryLight = extractNestedColor(opts.StockColors, "primary", "light") surface = extractNestedColor(opts.StockColors, "surface", "dark") if primaryDark == "" { return false, fmt.Errorf("failed to extract primary dark from stock colors") } if primaryLight == "" { primaryLight = primaryDark } dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode) importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON) importArgs = []string{"--import-json-string", importData} log.Info("Running matugen color hex with stock color overrides") args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()} args = appendContrastArg(args, opts.Contrast) args = append(args, importArgs...) if err := runMatugen(args); err != nil { return false, err } } else { log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value) matJSON, err := runMatugenDryRun(opts) if err != nil { return false, fmt.Errorf("matugen dry-run failed: %w", err) } primaryDark = extractMatugenColor(matJSON, "primary", "dark") primaryLight = extractMatugenColor(matJSON, "primary", "light") surface = extractMatugenColor(matJSON, "surface", "dark") if primaryDark == "" { return false, fmt.Errorf("failed to extract primary color") } if primaryLight == "" { primaryLight = primaryDark } dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode) importData := fmt.Sprintf(`{"dank16": %s}`, dank16JSON) importArgs = []string{"--import-json-string", importData} log.Infof("Running matugen %s with dank16 injection", opts.Kind) var args []string switch opts.Kind { case "hex": args = []string{"color", "hex", opts.Value} default: args = []string{opts.Kind, opts.Value} } args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()) args = appendContrastArg(args, opts.Contrast) args = append(args, importArgs...) if err := runMatugen(args); err != nil { return false, err } } newColors, _ := os.ReadFile(opts.ColorsOutput()) if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 { return false, nil } if opts.ColorsOnly { return true, nil } if isDMSGTKActive(opts.ConfigDir) { switch opts.Mode { case ColorModeLight: syncAccentColor(primaryLight) default: syncAccentColor(primaryDark) } refreshGTK(opts.Mode) refreshGTK4() } if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) { refreshQt6ct() } signalTerminals(opts) return true, nil } func appendContrastArg(args []string, contrast float64) []string { if contrast == 0 { return args } return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64)) } func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error { userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml") wroteConfig := false if opts.RunUserTemplates { if data, err := os.ReadFile(userConfigPath); err == nil { configSection := extractTOMLSection(string(data), "[config]", "[templates]") if configSection != "" { cfgFile.WriteString(configSection) cfgFile.WriteString("\n") wroteConfig = true } } } if !wroteConfig { cfgFile.WriteString("[config]\n\n") } baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml") if data, err := os.ReadFile(baseConfigPath); err == nil { content := string(data) lines := strings.Split(content, "\n") for _, line := range lines { if strings.TrimSpace(line) == "[config]" { continue } cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n") } cfgFile.WriteString("\n") } fmt.Fprintf(cfgFile, `[templates.dank] input_path = '%s/matugen/templates/dank.json' output_path = '%s' `, opts.ShellDir, opts.ColorsOutput()) if opts.ColorsOnly { return nil } homeDir, _ := os.UserHomeDir() for _, tmpl := range templateRegistry { if opts.ShouldSkipTemplate(tmpl.ID) { continue } switch tmpl.Kind { case TemplateKindGTK: switch opts.Mode { case ColorModeLight: appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml") default: appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml") } case TemplateKindTerminal: appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) case TemplateKindVSCode: appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir) appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir) case TemplateKindEmacs: if utils.EmacsConfigDir() != "" { appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) } default: appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile) } } if opts.RunUserTemplates { if data, err := os.ReadFile(userConfigPath); err == nil { templatesSection := extractTOMLSection(string(data), "[templates]", "") if templatesSection != "" { cfgFile.WriteString(templatesSection) cfgFile.WriteString("\n") } } } userPluginConfigDir := filepath.Join(opts.ConfigDir, "matugen", "dms", "configs") if entries, err := os.ReadDir(userPluginConfigDir); err == nil { for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".toml") { continue } if data, err := os.ReadFile(filepath.Join(userPluginConfigDir, entry.Name())); err == nil { cfgFile.WriteString(string(data)) cfgFile.WriteString("\n") } } } return nil } func appendConfig( opts *Options, cfgFile *os.File, checkCmd []string, checkFlatpaks []string, fileName string, ) { configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) if _, err := os.Stat(configPath); err != nil { return } if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { return } data, err := os.ReadFile(configPath) if err != nil { return } cfgFile.WriteString(substituteVars(string(data), opts.ShellDir)) cfgFile.WriteString("\n") } func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) { configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName) if _, err := os.Stat(configPath); err != nil { return } if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) { return } data, err := os.ReadFile(configPath) if err != nil { return } content := string(data) if !opts.TerminalsAlwaysDark { cfgFile.WriteString(substituteVars(content, opts.ShellDir)) cfgFile.WriteString("\n") return } lines := strings.Split(content, "\n") for _, line := range lines { if !strings.Contains(line, "input_path") || !strings.Contains(line, "SHELL_DIR/matugen/templates/") { continue } start := strings.Index(line, "'SHELL_DIR/matugen/templates/") if start == -1 { continue } end := strings.Index(line[start+1:], "'") if end == -1 { continue } templateName := line[start+len("'SHELL_DIR/matugen/templates/") : start+1+end] origPath := filepath.Join(opts.ShellDir, "matugen", "templates", templateName) origData, err := os.ReadFile(origPath) if err != nil { continue } modified := strings.ReplaceAll(string(origData), ".default.", ".dark.") tmpPath := filepath.Join(tmpDir, templateName) if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil { continue } content = strings.ReplaceAll(content, fmt.Sprintf("'SHELL_DIR/matugen/templates/%s'", templateName), fmt.Sprintf("'%s'", tmpPath)) } cfgFile.WriteString(substituteVars(content, opts.ShellDir)) cfgFile.WriteString("\n") } func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool { // Both nil is treated as "skip check" / unconditionally run if checkCmd == nil && checkFlatpaks == nil { return true } if checkCmd != nil && checker.AnyCommandExists(checkCmd...) { return true } if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) { return true } return false } func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) { pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*") matches, err := filepath.Glob(pattern) if err != nil || len(matches) == 0 { return } extDir := matches[0] templateDir := filepath.Join(shellDir, "matugen", "templates") fmt.Fprintf(cfgFile, `[templates.dms%sdefault] input_path = '%s/vscode-color-theme-default.json' output_path = '%s/themes/dankshell-default.json' [templates.dms%sdark] input_path = '%s/vscode-color-theme-dark.json' output_path = '%s/themes/dankshell-dark.json' [templates.dms%slight] input_path = '%s/vscode-color-theme-light.json' output_path = '%s/themes/dankshell-light.json' `, name, templateDir, extDir, name, templateDir, extDir, name, templateDir, extDir) log.Infof("Added %s theme config (extension found at %s)", name, extDir) } func substituteVars(content, shellDir string) string { result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/") result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/") result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/") result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/") if emacsDir := utils.EmacsConfigDir(); emacsDir != "" { result = strings.ReplaceAll(result, "'EMACS_DIR/", "'"+emacsDir+"/") } return result } func extractTOMLSection(content, startMarker, endMarker string) string { startIdx := strings.Index(content, startMarker) if startIdx == -1 { return "" } if endMarker == "" { return content[startIdx:] } endIdx := strings.Index(content[startIdx:], endMarker) if endIdx == -1 { return content[startIdx:] } return content[startIdx : startIdx+endIdx] } type matugenFlags struct { supportsCOE bool isV4 bool } func detectMatugenVersion() (matugenFlags, error) { matugenVersionMu.Lock() defer matugenVersionMu.Unlock() if matugenVersionOK { return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil } return detectMatugenVersionLocked() } func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) { matugenVersionMu.Lock() defer matugenVersionMu.Unlock() matugenVersionOK = false flags, err := detectMatugenVersionLocked() if err != nil { return old, false } changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4 return flags, changed } func detectMatugenVersionLocked() (matugenFlags, error) { cmd := exec.Command("matugen", "--version") output, err := cmd.Output() if err != nil { return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err) } versionStr := strings.TrimSpace(string(output)) versionStr = strings.TrimPrefix(versionStr, "matugen ") parts := strings.Split(versionStr, ".") if len(parts) < 2 { return matugenFlags{}, fmt.Errorf("unexpected matugen version format: %q", versionStr) } major, err := strconv.Atoi(parts[0]) if err != nil { return matugenFlags{}, fmt.Errorf("failed to parse matugen major version %q: %w", parts[0], err) } minor, err := strconv.Atoi(parts[1]) if err != nil { return matugenFlags{}, fmt.Errorf("failed to parse matugen minor version %q: %w", parts[1], err) } matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1) matugenIsV4 = major >= 4 matugenVersionOK = true if matugenSupportsCOE { log.Debugf("Matugen %s detected: continue-on-error support enabled", versionStr) } if matugenIsV4 { log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr) } return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil } func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string { args := make([]string, 0, len(baseArgs)+4) if flags.supportsCOE { args = append(args, "--continue-on-error") } args = append(args, baseArgs...) if flags.isV4 { args = append(args, "--source-color-index", "0") } return args } func runMatugen(baseArgs []string) error { flags, err := detectMatugenVersion() if err != nil { return err } args := buildMatugenArgs(baseArgs, flags) cmd := exec.Command("matugen", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr runErr := cmd.Run() if runErr == nil { return nil } log.Warnf("Matugen failed (v4=%v): %v", flags.isV4, runErr) newFlags, changed := redetectMatugenVersion(flags) if !changed { return runErr } log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4) args = buildMatugenArgs(baseArgs, newFlags) retryCmd := exec.Command("matugen", args...) retryCmd.Stdout = os.Stdout retryCmd.Stderr = os.Stderr return retryCmd.Run() } func runMatugenDryRun(opts *Options) (string, error) { flags, err := detectMatugenVersion() if err != nil { return "", err } output, dryErr := execDryRun(opts, flags) if dryErr == nil { return output, nil } log.Warnf("Matugen dry-run failed (v4=%v): %v", flags.isV4, dryErr) newFlags, changed := redetectMatugenVersion(flags) if !changed { return "", dryErr } log.Warnf("Matugen version changed (v4: %v -> %v), retrying dry-run", flags.isV4, newFlags.isV4) return execDryRun(opts, newFlags) } func execDryRun(opts *Options, flags matugenFlags) (string, error) { var baseArgs []string switch opts.Kind { case "hex": baseArgs = []string{"color", "hex", opts.Value} default: baseArgs = []string{opts.Kind, opts.Value} } baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run") baseArgs = appendContrastArg(baseArgs, opts.Contrast) if flags.isV4 { baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output") } cmd := exec.Command("matugen", baseArgs...) var stderr strings.Builder cmd.Stderr = &stderr output, err := cmd.Output() if err != nil { if stderr.Len() > 0 { return "", fmt.Errorf("matugen %v failed (v4=%v): %s", baseArgs, flags.isV4, strings.TrimSpace(stderr.String())) } return "", fmt.Errorf("matugen %v failed (v4=%v): %w", baseArgs, flags.isV4, err) } return strings.ReplaceAll(string(output), "\n", ""), nil } func extractMatugenColor(jsonStr, colorName, variant string) string { var data map[string]any if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { return "" } colors, ok := data["colors"].(map[string]any) if !ok { return "" } colorData, ok := colors[colorName].(map[string]any) if !ok { return "" } variantData, ok := colorData[variant].(string) if !ok { return "" } return variantData } func extractNestedColor(jsonStr, colorName, variant string) string { var data map[string]any if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { return "" } colorData, ok := data[colorName].(map[string]any) if !ok { return "" } variantData, ok := colorData[variant].(map[string]any) if !ok { return "" } color, ok := variantData["color"].(string) if !ok { return "" } return color } func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string { variantOpts := dank16.VariantOptions{ PrimaryDark: primaryDark, PrimaryLight: primaryLight, Background: surface, UseDPS: true, IsLightMode: mode == ColorModeLight, } variantColors := dank16.GenerateVariantPalette(variantOpts) return dank16.GenerateVariantJSON(variantColors) } func isDMSGTKActive(configDir string) bool { gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css") info, err := os.Lstat(gtkCSS) if err != nil { return false } if info.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(gtkCSS) return err == nil && strings.Contains(target, "dank-colors.css") } data, err := os.ReadFile(gtkCSS) return err == nil && strings.Contains(string(data), "dank-colors.css") } func refreshGTK(mode ColorMode) { if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil { log.Warnf("Failed to reset gtk-theme: %v", err) } if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil { log.Warnf("Failed to set gtk-theme: %v", err) } } func refreshGTK4() { output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme") if err != nil { return } current := strings.Trim(output, "'") var toggle string if current == "prefer-dark" { toggle = "default" } else { toggle = "prefer-dark" } if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil { log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err) return } time.Sleep(50 * time.Millisecond) if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil { log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err) } } func refreshQt6ct() { confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf") now := time.Now() if err := os.Chtimes(confPath, now, now); err != nil { log.Warnf("Failed to touch qt6ct.conf: %v", err) } } func signalTerminals(opts *Options) { if !opts.ShouldSkipTemplate("kitty") && appExists(opts.AppChecker, []string{"kitty"}, nil) { signalByName("kitty", syscall.SIGUSR1) signalByName(".kitty-wrapped", syscall.SIGUSR1) } if !opts.ShouldSkipTemplate("ghostty") && appExists(opts.AppChecker, []string{"ghostty"}, nil) { signalByName("ghostty", syscall.SIGUSR2) signalByName(".ghostty-wrappe", syscall.SIGUSR2) } } func signalByName(name string, sig syscall.Signal) { entries, err := os.ReadDir("/proc") if err != nil { return } for _, entry := range entries { pid, err := strconv.Atoi(entry.Name()) if err != nil { continue } comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm")) if err != nil { continue } if strings.TrimSpace(string(comm)) == name { syscall.Kill(pid, sig) } } } func syncColorScheme(mode ColorMode) { scheme := "prefer-dark" if mode == ColorModeLight { scheme = "default" } if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil { log.Warnf("Failed to sync color-scheme: %v", err) } } var adwaitaAccents = []struct { name string colors []colorful.Color }{ {"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")}, {"green", hexColors("#26a269", "#39ac76", "#81d5ad")}, {"orange", hexColors("#f17738", "#ff7800", "#ffc994")}, {"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")}, {"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")}, {"red", hexColors("#e84053", "#e01b24", "#f2a1a5")}, {"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")}, {"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")}, {"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")}, } func hexColors(hexes ...string) []colorful.Color { out := make([]colorful.Color, len(hexes)) for i, h := range hexes { out[i], _ = colorful.Hex(h) } return out } func closestAdwaitaAccent(primaryHex string) string { c, err := colorful.Hex(primaryHex) if err != nil { return "blue" } best := "blue" bestDist := math.MaxFloat64 for _, a := range adwaitaAccents { for _, ref := range a.colors { d := c.DistanceCIEDE2000(ref) if d < bestDist { bestDist = d best = a.name } } } return best } func syncAccentColor(primaryHex string) { accent := closestAdwaitaAccent(primaryHex) log.Infof("Setting GNOME accent color: %s", accent) if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil { log.Warnf("Failed to set accent-color: %v", err) } } type TemplateCheck struct { ID string `json:"id"` Detected bool `json:"detected"` } func CheckTemplates(checker utils.AppChecker) []TemplateCheck { if checker == nil { checker = utils.DefaultAppChecker{} } homeDir, _ := os.UserHomeDir() checks := make([]TemplateCheck, 0, len(templateRegistry)) for _, tmpl := range templateRegistry { detected := false switch { case tmpl.RunUnconditionally: detected = true case tmpl.Kind == TemplateKindVSCode: detected = checkVSCodeExtension(homeDir) case tmpl.Kind == TemplateKindEmacs: detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != "" default: detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) } checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected}) } return checks } func checkVSCodeExtension(homeDir string) bool { extDirs := []string{ filepath.Join(homeDir, ".vscode/extensions"), filepath.Join(homeDir, ".vscode-oss/extensions"), filepath.Join(homeDir, ".config/Code - OSS/extensions"), filepath.Join(homeDir, ".cursor/extensions"), filepath.Join(homeDir, ".windsurf/extensions"), } for _, extDir := range extDirs { pattern := filepath.Join(extDir, "danklinux.dms-theme-*") if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 { return true } } return false }