Files
mesh-catalog/modules/xdg/cmd/xdg-tools/desktop.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

235 lines
6.5 KiB
Go

package main
import (
"bufio"
"os"
"path/filepath"
"sort"
"strings"
)
// KeyFile is a freedesktop key file (a desktop entry, a mimeapps list): groups of key=value.
// Readers disagree on a group or a key written twice (GLib keeps the last, xdg-mime the first),
// so both are recorded rather than merged silently.
type KeyFile struct {
Groups map[string]map[string]string
Order []string
DupGroups []string
DupKeys []string // "group: key"
Unparsable []int // line numbers
}
// ParseKeyFile reads a key file; `#` lines are comments. A localised key (Name[de]) is kept under
// its own name.
func ParseKeyFile(text string) KeyFile {
k := KeyFile{Groups: map[string]map[string]string{}}
group := ""
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 64<<10), 1<<20)
n := 0
for sc.Scan() {
n++
l := strings.TrimSpace(sc.Text())
if l == "" || strings.HasPrefix(l, "#") {
continue
}
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
group = l[1 : len(l)-1]
if _, seen := k.Groups[group]; seen {
k.DupGroups = append(k.DupGroups, group)
} else {
k.Groups[group] = map[string]string{}
k.Order = append(k.Order, group)
}
continue
}
key, value, ok := strings.Cut(l, "=")
if !ok || group == "" {
k.Unparsable = append(k.Unparsable, n)
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
if _, seen := k.Groups[group][key]; seen {
k.DupKeys = append(k.DupKeys, group+": "+key)
// The first value is kept, as xdg-mime reads it; the duplicate is reported.
continue
}
k.Groups[group][key] = value
}
return k
}
// List splits a key's value at `;`, dropping empty items.
func List(v string) []string {
var out []string
for _, s := range strings.Split(v, ";") {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// Entry is one desktop entry, as the tools need it.
type Entry struct {
ID string `json:"id"`
File string `json:"file"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Exec string `json:"exec,omitempty"`
TryExec string `json:"try_exec,omitempty"`
Program string `json:"program,omitempty"`
Found bool `json:"program_found"`
MimeTypes []string `json:"mime_types,omitempty"`
Hidden bool `json:"hidden,omitempty"`
NoDisplay bool `json:"no_display,omitempty"`
OnlyShow []string `json:"only_show_in,omitempty"`
NotShow []string `json:"not_show_in,omitempty"`
// GnomeEnabled is X-GNOME-Autostart-enabled as written ("" when absent).
GnomeEnabled string `json:"x_gnome_autostart_enabled,omitempty"`
Broken string `json:"broken,omitempty"`
}
// readEntry reads one desktop file; a dangling link or an unreadable file is an entry that says so.
func (m *Machine) readEntry(path string) Entry {
e := Entry{ID: filepath.Base(path), File: m.tilde(path)}
text, ok := m.read(path)
if !ok {
if _, err := os.Lstat(m.p(path)); err == nil {
e.Broken = "a link to nothing, or unreadable"
} else {
e.Broken = "absent"
}
return e
}
g := ParseKeyFile(text).Groups["Desktop Entry"]
if g == nil {
e.Broken = "no [Desktop Entry] group"
return e
}
e.Name, e.Type, e.Exec, e.TryExec = g["Name"], g["Type"], g["Exec"], g["TryExec"]
e.MimeTypes = List(g["MimeType"])
e.Hidden = g["Hidden"] == "true"
e.NoDisplay = g["NoDisplay"] == "true"
e.OnlyShow, e.NotShow = List(g["OnlyShowIn"]), List(g["NotShowIn"])
e.GnomeEnabled = g["X-GNOME-Autostart-enabled"]
e.Program = Program(e.Exec)
if e.TryExec != "" {
_, tryFound := m.executable(e.TryExec)
_, execFound := m.executable(e.Program)
e.Found = tryFound && execFound
} else {
_, e.Found = m.executable(e.Program)
}
return e
}
// Program is the program an Exec line runs: its first word, unquoted, past `env` and its
// assignments, and into the command of `sh -c` (and bash's, zsh's), where a launcher hides it.
func Program(exec string) string {
words := splitExec(exec)
for len(words) > 0 {
w := words[0]
switch {
case filepath.Base(w) == "env":
words = words[1:]
for len(words) > 0 && (strings.Contains(words[0], "=") || strings.HasPrefix(words[0], "-")) {
words = words[1:]
}
continue
case isShell(w) && len(words) >= 3 && words[1] == "-c":
words = splitExec(words[2])
continue
}
return w
}
return ""
}
func isShell(w string) bool {
switch filepath.Base(w) {
case "sh", "bash", "zsh", "dash":
return true
}
return false
}
// splitExec splits an Exec value into words: double quotes as the desktop-entry specification
// says (with backslash escapes), single quotes as a shell would, field codes left as words.
func splitExec(s string) []string {
var words []string
var cur strings.Builder
in, quote, started := false, byte(0), false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case in && c == quote:
in = false
case in && quote == '"' && c == '\\' && i+1 < len(s):
i++
cur.WriteByte(s[i])
case in:
cur.WriteByte(c)
case c == '"' || c == '\'':
in, quote, started = true, c, true
case c == ' ' || c == '\t':
if started {
words = append(words, cur.String())
cur.Reset()
started = false
}
default:
cur.WriteByte(c)
started = true
}
}
if started {
words = append(words, cur.String())
}
return words
}
// appDirs are where desktop files are found, most preferred first.
func (m *Machine) appDirs() []string {
dirs := []string{filepath.Join(m.dataHome(), "applications")}
for _, d := range m.dataDirs() {
dirs = append(dirs, filepath.Join(d, "applications"))
}
return dirs
}
// findApp finds the desktop file an id names, as an application would: the first directory that
// has it. A link to nothing, or an entry marked Hidden, is not an installed application.
func (m *Machine) findApp(id string) (Entry, bool) {
for _, d := range m.appDirs() {
path := filepath.Join(d, id)
if _, err := os.Lstat(m.p(path)); err != nil {
continue
}
e := m.readEntry(path)
if e.Broken != "" || e.Hidden {
return e, false
}
return e, true
}
return Entry{ID: id}, false
}
// apps are every desktop file in the application directories, the first of each id.
func (m *Machine) apps() []Entry {
seen := map[string]bool{}
var out []Entry
for _, d := range m.appDirs() {
entries, _ := os.ReadDir(m.p(d))
for _, de := range entries {
if !strings.HasSuffix(de.Name(), ".desktop") || seen[de.Name()] {
continue
}
seen[de.Name()] = true
out = append(out, m.readEntry(filepath.Join(d, de.Name())))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}