Files
mesh-catalog/modules/xdg/cmd/xdg-tools/userdirs.go
T
jochen 785d32407b xdg: own the account's folders and the machine's default applications
The workstations' XDG conventions had no owner: xdg-user-dirs-update rewrote
the folders file at every login, both machines' default-application lists
named an editor neither has, and the laptop kept two dead links of the
retired predecessor. The module adopts the operator's folders (three as
settings, as the machines differ), disables the update so it cannot undo the
mesh's file, and writes the machine's own mimeapps list, the last one read,
so the person's choices in their own list always win. Autostart is listed,
not owned: each entry is its application's module's.
2026-10-05 11:50:34 +02:00

156 lines
4.6 KiB
Go

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")
}