package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "time" ) // modes are feh's background modes, by the word feh_set takes. var modes = []string{"fill", "center", "max", "scale", "tile"} // Wallpaper is images and how they are laid on the screens. type Wallpaper struct { Images []string `json:"images"` Mode string `json:"mode"` At string `json:"at,omitempty"` } func fehbg() string { return filepath.Join(operatorHome(), ".fehbg") } // sessionRecord is where feh_set notes what it put up, for feh_current: in the runtime directory, // so it lasts exactly as long as the login, like the wallpaper itself. func sessionRecord(s Session) string { if s.RuntimeDir == "" { return "" } return filepath.Join(s.RuntimeDir, "feh", "current.json") } // SetResult is what feh_set answers. type SetResult struct { Wallpaper Note string `json:"note"` } // Set puts images up as the wallpaper for this session. func Set(images []string, mode string) (SetResult, error) { if mode == "" { mode = "fill" } known := false for _, m := range modes { known = known || m == mode } if !known { return SetResult{}, fmt.Errorf("mode %q is one of %s", mode, strings.Join(modes, ", ")) } if len(images) == 0 { return SetResult{}, errors.New("images is required: at least one image") } var paths []string for _, img := range images { p := img if strings.HasPrefix(p, "~/") { p = filepath.Join(operatorHome(), p[2:]) } if !filepath.IsAbs(p) { p = filepath.Join(operatorHome(), p) } info, err := os.Stat(p) if err != nil { return SetResult{}, fmt.Errorf("image %s: %w", img, err) } if info.IsDir() { return SetResult{}, fmt.Errorf("image %s is a directory", img) } paths = append(paths, p) } s, err := findSession() if err != nil { return SetResult{}, err } args := append([]string{"--no-fehbg", "--bg-" + mode}, paths...) r, err := s.run(15*time.Second, "", "feh", args...) if err != nil { return SetResult{}, err } if r.Code != 0 { return SetResult{}, fmt.Errorf("feh: %s", strings.TrimSpace(r.Stderr)) } w := Wallpaper{Images: paths, Mode: mode, At: time.Now().Format(time.RFC3339)} if rec := sessionRecord(s); rec != "" { if err := os.MkdirAll(filepath.Dir(rec), 0o700); err == nil { raw, _ := json.Marshal(w) _ = os.WriteFile(rec, raw, 0o600) } } return SetResult{Wallpaper: w, Note: "for this session; the declared wallpaper returns at the next login"}, nil } // CurrentResult is what feh_current answers. type CurrentResult struct { Declared *Wallpaper `json:"declared"` Session *Wallpaper `json:"session,omitempty"` } var bgMode = regexp.MustCompile(`--bg-(fill|center|max|scale|tile)\b`) // Current is the declared wallpaper and the one set in this session. func Current() (CurrentResult, error) { var out CurrentResult if raw, err := os.ReadFile(fehbg()); err == nil { out.Declared = parseFehbg(string(raw), operatorHome()) } if rec := sessionRecord(findEnvironment()); rec != "" { if raw, err := os.ReadFile(rec); err == nil { var w Wallpaper if json.Unmarshal(raw, &w) == nil { out.Session = &w } } } return out, nil } // parseFehbg reads the feh line of a ~/.fehbg: its mode and its images, with $HOME expanded. func parseFehbg(script, home string) *Wallpaper { for _, line := range strings.Split(script, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "feh ") { continue } w := &Wallpaper{Images: []string{}} if m := bgMode.FindStringSubmatch(line); m != nil { w.Mode = m[1] } for _, word := range shellWords(line)[1:] { if strings.HasPrefix(word, "-") { continue } word = strings.ReplaceAll(strings.ReplaceAll(word, "${HOME}", home), "$HOME", home) w.Images = append(w.Images, word) } return w } return nil } // shellWords splits a simple command line on blanks, honouring single and double quotes. func shellWords(line string) []string { var words []string var cur strings.Builder var quote byte in := false for i := 0; i < len(line); i++ { c := line[i] switch { case quote != 0 && c == quote: quote = 0 case quote != 0: cur.WriteByte(c) case c == '\'' || c == '"': quote, in = c, true case c == ' ' || c == '\t': if in { words = append(words, cur.String()) cur.Reset() in = false } default: cur.WriteByte(c) in = true } } if in { words = append(words, cur.String()) } return words }