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.
235 lines
6.5 KiB
Go
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
|
|
}
|