package main import ( "bufio" "os" "path/filepath" "regexp" "strings" ) // meshMark is in the first lines of every file this module writes. const meshMark = "(module xdg" // UserDir is one line of user-dirs.dirs. type UserDir struct { Name string `json:"name"` Written string `json:"written"` Path string `json:"path"` // Home is a folder pointed at the home itself: xdg-user-dirs' way of saying "not wanted". Home bool `json:"is_home"` Exists bool `json:"exists"` Kind string `json:"kind"` // directory, file, absent // Default is the folder xdg-user-dirs would have made, from its system defaults. Default string `json:"xdg_user_dirs_default,omitempty"` } var userDirLine = regexp.MustCompile(`^XDG_([A-Z0-9_]+)_DIR="(.*)"\s*$`) // ParseUserDirs reads user-dirs.dirs as xdg-user-dirs writes it: XDG_xxx_DIR="$HOME/yyy" or "/yyy". // The order of the file is kept. func ParseUserDirs(text string) [][2]string { var out [][2]string sc := bufio.NewScanner(strings.NewReader(text)) for sc.Scan() { if m := userDirLine.FindStringSubmatch(strings.TrimSpace(sc.Text())); m != nil { out = append(out, [2]string{m[1], strings.ReplaceAll(m[2], `\`, "")}) } } return out } // resolve turns a written value into a path, as xdg-user-dir does. func (m *Machine) resolve(written string) string { switch { case written == "$HOME" || written == "$HOME/": return m.Home case strings.HasPrefix(written, "$HOME/"): return filepath.Clean(filepath.Join(m.Home, strings.TrimPrefix(written, "$HOME/"))) } return filepath.Clean(written) } func (m *Machine) kind(path string) string { info, err := os.Stat(m.p(path)) switch { case err != nil: return "absent" case info.IsDir(): return "directory" } return "file" } // UserDirsAnswer is xdg_user_dirs' answer. type UserDirsAnswer struct { File string `json:"file"` Meshs bool `json:"written_by_the_mesh"` Dirs []UserDir `json:"dirs"` Missing []string `json:"defaults_without_a_line,omitempty"` Update struct { Enabled bool `json:"enabled"` DecidedBy string `json:"decided_by"` Note string `json:"note"` } `json:"xdg_user_dirs_update"` Locale string `json:"locale,omitempty"` } // userDirsDefaults reads the names xdg-user-dirs would create, from its system defaults. func (m *Machine) userDirsDefaults() map[string]string { out := map[string]string{} text, _ := m.read("/etc/xdg/user-dirs.defaults") for _, l := range strings.Split(text, "\n") { l = strings.TrimSpace(l) if l == "" || strings.HasPrefix(l, "#") { continue } if k, v, ok := strings.Cut(l, "="); ok { out[k] = v } } return out } // updateEnabled is xdg-user-dirs-update's own reading of `enabled`: the account's user-dirs.conf // over the system's, the last line saying it winning. func (m *Machine) updateEnabled() (bool, string) { enabled, by := true, "xdg-user-dirs' built-in default" for _, f := range []string{"/etc/xdg/user-dirs.conf", filepath.Join(m.configHome(), "user-dirs.conf")} { text, ok := m.read(f) if !ok { continue } for _, l := range strings.Split(text, "\n") { k, v, ok := strings.Cut(strings.TrimSpace(l), "=") if ok && strings.TrimSpace(k) == "enabled" { enabled, by = strings.EqualFold(strings.TrimSpace(v), "true"), m.tilde(f) } } } return enabled, by } // UserDirs is xdg_user_dirs. func (m *Machine) UserDirs() UserDirsAnswer { var a UserDirsAnswer file := filepath.Join(m.configHome(), "user-dirs.dirs") a.File = m.tilde(file) text, _ := m.read(file) a.Meshs = strings.Contains(firstLines(text, 3), meshMark) defaults := m.userDirsDefaults() seen := map[string]bool{} for _, kv := range ParseUserDirs(text) { path := m.resolve(kv[1]) d := UserDir{Name: kv[0], Written: kv[1], Path: m.tilde(path), Home: path == m.Home, Kind: m.kind(path)} d.Exists = d.Kind == "directory" if def, ok := defaults[kv[0]]; ok { d.Default = "~/" + def } seen[kv[0]] = true a.Dirs = append(a.Dirs, d) } for _, k := range sortedKeys(defaults) { if !seen[k] { a.Missing = append(a.Missing, k) } } a.Update.Enabled, a.Update.DecidedBy = m.updateEnabled() if a.Update.Enabled { a.Update.Note = "enabled: at the next login it reassigns a folder it cannot find to the home, adds a line for each default above, and rewrites the file" } else { a.Update.Note = "disabled: it leaves the file as it is" } if l, ok := m.read(filepath.Join(m.configHome(), "user-dirs.locale")); ok { a.Locale = strings.TrimSpace(l) } return a } func firstLines(text string, n int) string { lines := strings.SplitN(text, "\n", n+1) if len(lines) > n { lines = lines[:n] } return strings.Join(lines, "\n") }