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 }