package main import ( "errors" "fmt" "io" "os" "os/exec" "regexp" "strings" ) var pairNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) type usageError struct{} func (usageError) Error() string { return "invalid arguments" } func main() { if err := runCLI(os.Args[1:], os.Stdout, os.Stderr); err != nil { var usage usageError if errors.As(err, &usage) { printHelp(os.Stderr, defaultStatePath()) os.Exit(2) } fmt.Fprintf(os.Stderr, "tint: %v\n", err) os.Exit(1) } } func runCLI(args []string, stdout, stderr io.Writer) error { statePath := defaultStatePath() if len(args) == 2 && args[0] == "apply-apps" { return applyApps(args[1]) } if len(args) == 0 { printHelp(stdout, statePath) return nil } if len(args) == 1 { switch args[0] { case "-h", "--help": printHelp(stdout, statePath) return nil case "config": data, err := os.ReadFile(statePath) if err != nil { return err } _, err = stdout.Write(data) return err case "light", "dark": value, err := loadState(statePath) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if err := validatePair(value.Current, stderr); err != nil { return err } return applySelected(value.Current, args[0], stdout, stderr) } if mode, theme, ok := parseThemeArg(args[0]); ok { value, err := loadState(statePath) if err != nil { return usageError{} } if mode == "light" { value.Current.Light = theme } else { value.Current.Dark = theme } return updateAndApply(statePath, value, "", stdout, stderr) } value, err := loadState(statePath) if err != nil { return err } named, _, found := value.Catalog.findPair(args[0]) if !found { return fmt.Errorf("unknown pair: %s", args[0]) } value.Current = currentPair{Light: named.Light, Dark: named.Dark} return updateAndApply(statePath, value, "", stdout, stderr) } if len(args) == 2 { if args[0] == "favorite" || args[0] == "unfavorite" { value, err := loadState(statePath) if err != nil { return err } _, _, isPair := value.Catalog.findPair(args[1]) if !isPair && args[0] == "favorite" { if err := validateTheme(args[1], stderr); err != nil { return err } } if args[0] == "favorite" { value.Catalog.favorite(args[1]) } else { value.Catalog.unfavorite(args[1]) } return saveState(statePath, value) } firstMode, firstTheme, firstOK := parseThemeArg(args[0]) secondMode, secondTheme, secondOK := parseThemeArg(args[1]) if !firstOK || !secondOK || firstMode == secondMode { return usageError{} } value, err := loadState(statePath) if errors.Is(err, os.ErrNotExist) { value = state{} } else if err != nil { return err } if firstMode == "light" { value.Current.Light, value.Current.Dark = firstTheme, secondTheme } else { value.Current.Dark, value.Current.Light = firstTheme, secondTheme } return updateAndApply(statePath, value, "", stdout, stderr) } if len(args) == 4 && args[0] == "pair" { if !pairNamePattern.MatchString(args[1]) || reservedPairName(args[1]) { return fmt.Errorf("invalid pair name: %s", args[1]) } darkMode, dark, darkOK := parseThemeArg(args[2]) lightMode, light, lightOK := parseThemeArg(args[3]) if !darkOK || !lightOK || darkMode != "dark" || lightMode != "light" { return usageError{} } if err := validatePair(currentPair{Light: light, Dark: dark}, stderr); err != nil { return err } value, err := loadState(statePath) if err != nil { return err } value.Catalog.configurePair(themePair{Name: args[1], Dark: dark, Light: light}) return saveState(statePath, value) } return usageError{} } func parseThemeArg(arg string) (string, string, bool) { mode, theme, ok := strings.Cut(arg, ":") if !ok || theme == "" || (mode != "light" && mode != "dark") { return "", "", false } return mode, theme, true } func reservedPairName(name string) bool { switch name { case "config", "light", "dark", "pair", "favorite", "unfavorite", "help", "-h", "--help", "apply-apps": return true default: return false } } func updateAndApply(path string, value state, requested string, stdout, stderr io.Writer) error { if err := validatePair(value.Current, stderr); err != nil { return err } value.Catalog.record(value.Current) if err := saveState(path, value); err != nil { return err } var err error if requested == "" { requested, err = currentAppearance() if err != nil { return err } } return applySelected(value.Current, requested, stdout, stderr) } func validatePair(pair currentPair, stderr io.Writer) error { if pair.Light == "" || pair.Dark == "" { return usageError{} } if err := validateTheme(pair.Light, stderr); err != nil { return err } return validateTheme(pair.Dark, stderr) } func validateTheme(theme string, stderr io.Writer) error { cmd := exec.Command("tinty", "info", theme) cmd.Stdout = io.Discard cmd.Stderr = stderr return cmd.Run() } func currentAppearance() (string, error) { if appearance := os.Getenv("TINTY_PAIR_APPEARANCE"); appearance != "" { if appearance != "light" && appearance != "dark" { return "", fmt.Errorf("TINTY_PAIR_APPEARANCE must be light or dark") } return appearance, nil } output, err := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle").Output() if err == nil && strings.TrimSpace(string(output)) == "Dark" { return "dark", nil } return "light", nil } func applySelected(pair currentPair, appearance string, stdout, stderr io.Writer) error { theme := pair.Light if appearance == "dark" { theme = pair.Dark } cmd := exec.Command("tinty", "apply", theme) cmd.Stdout = stdout cmd.Stderr = stderr return cmd.Run() } func printCurrent(writer io.Writer, path string) { value, err := loadState(path) if err != nil { value.Current = currentPair{Light: "-", Dark: "-"} } fmt.Fprintf(writer, "[current]\nlight:%s\ndark:%s\n\n", value.Current.Light, value.Current.Dark) } func printHelp(writer io.Writer, statePath string) { io.WriteString(writer, `Usage: tint Show this help tint config Print the configuration tint favorite Add a theme or pair to favorites tint unfavorite Remove a theme or pair from favorites tint Switch to a saved pair tint pair dark: light: Save a named theme pair tint light|dark Apply the saved light or dark theme tint light: Set the light theme tint dark: Set the dark theme tint light: dark: Set both themes `) printCurrent(writer, statePath) }