package main import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" ) // MeshList is the machine's default applications, the one mimeapps list this module writes. const MeshList = "/etc/xdg/mimeapps.list" // mimeLists are the mimeapps lists in the order an application consults them (the XDG MIME // Applications specification): the account's configuration, the system's, then the deprecated // lists in the application directories. func (m *Machine) mimeLists() []string { var dirs []string dirs = append(dirs, m.configHome()) dirs = append(dirs, m.configDirs()...) dirs = append(dirs, filepath.Join(m.dataHome(), "applications")) for _, d := range m.dataDirs() { dirs = append(dirs, filepath.Join(d, "applications")) } var out []string for _, d := range dirs { if m.Desktop != "" { out = append(out, filepath.Join(d, m.Desktop+"-mimeapps.list")) } out = append(out, filepath.Join(d, "mimeapps.list")) } return out } // deprecated is whether a list sits in an application directory, where the specification no // longer looks first and the person no longer writes. func (m *Machine) deprecated(list string) bool { return strings.HasSuffix(filepath.Dir(list), "/applications") } // MimeList is one mimeapps list that exists. type MimeList struct { File string `json:"file"` Whose string `json:"whose"` Broken string `json:"broken,omitempty"` Keys KeyFile `json:"-"` } func (m *Machine) whose(list string) string { switch { case list == MeshList: return "the mesh's (module xdg)" case strings.HasPrefix(list, m.Home+"/") && !m.deprecated(list): return "the person's: programs and xdg_default write here; the mesh never does" case m.deprecated(list): return "deprecated location: read last, written by nothing current" } return "the system's, as found" } // lists are the mimeapps lists that exist, in order. func (m *Machine) lists() []MimeList { var out []MimeList for _, f := range m.mimeLists() { if _, err := os.Lstat(m.p(f)); err != nil { continue } l := MimeList{File: m.tilde(f), Whose: m.whose(f)} if text, ok := m.read(f); ok { l.Keys = ParseKeyFile(text) } else { l.Broken = "a link to nothing, or unreadable" } out = append(out, l) } return out } // Decision is how a type's default was found. type Decision struct { ID string `json:"id,omitempty"` File string `json:"decided_by,omitempty"` Skipped []string `json:"skipped,omitempty"` // "id (file): why" } // Decide finds a type's default as the specification says: the first list naming it in // [Default Applications], the first application of its value that is installed; an application // named but not installed is passed over, to the next one or the next list. func (m *Machine) Decide(mime string) Decision { var d Decision for _, l := range m.lists() { v, ok := l.Keys.Groups["Default Applications"][mime] if !ok { continue } for _, id := range List(v) { if _, installed := m.findApp(id); installed { d.ID, d.File = id, l.File return d } d.Skipped = append(d.Skipped, fmt.Sprintf("%s (%s): not installed", id, l.File)) } } return d } var ( mimeType = regexp.MustCompile(`^[a-z0-9][a-z0-9.+-]*/[A-Za-z0-9][A-Za-z0-9.+_-]*$`) urlScheme = regexp.MustCompile(`^[a-z][a-z0-9+.-]*$`) desktopID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 ._+-]*\.desktop$`) ) // Normalise accepts a MIME type (text/html), a URL scheme (https, mailto:) or the scheme's own // type (x-scheme-handler/https), and answers the type. func Normalise(t string) (string, error) { t = strings.TrimSpace(t) if s := strings.TrimSuffix(strings.TrimSuffix(t, "://"), ":"); urlScheme.MatchString(s) { return "x-scheme-handler/" + s, nil } if mimeType.MatchString(t) { return t, nil } return "", fmt.Errorf("%q is neither a MIME type (text/html) nor a URL scheme (https, mailto)", t) } // Handler is who opens one type, and with what. type Handler struct { Type string `json:"type"` XdgOpen string `json:"xdg_open"` Gio string `json:"gtk_programs,omitempty"` Disagree bool `json:"readers_disagree,omitempty"` Decision Decision `json:"decision"` App *Entry `json:"application,omitempty"` Problem string `json:"problem,omitempty"` } // queryDefault asks xdg-mime, which is what xdg-open uses. func (m *Machine) queryDefault(mime string) (string, error) { out, err := m.cmd("xdg-mime", "query", "default", mime) return strings.TrimSpace(out), err } var gioDefault = regexp.MustCompile(`(?m)^Default application for .*: (.+)$`) // gioDefault asks GLib, which GTK and Electron programs use; "" when it has none or no gio. func (m *Machine) gioDefault(mime string) string { out, err := m.cmd("gio", "mime", mime) if err != nil { return "" } if g := gioDefault.FindStringSubmatch(out); g != nil { return strings.TrimSpace(g[1]) } return "" } // Handle answers who opens a type. func (m *Machine) Handle(mime string) Handler { h := Handler{Type: mime, Decision: m.Decide(mime)} id, err := m.queryDefault(mime) if err != nil { h.Problem = err.Error() } h.XdgOpen = id h.Gio = m.gioDefault(mime) h.Disagree = h.Gio != "" && h.XdgOpen != "" && h.Gio != h.XdgOpen if id != "" { if e, ok := m.findApp(id); ok { h.App = &e if !e.Found { h.Problem = fmt.Sprintf("%s runs %s, which is not installed", id, e.Program) } } else { h.Problem = id + " is not installed" } } else if h.Problem == "" { h.Problem = "nothing opens it" } if h.Decision.ID == "" && id != "" { h.Decision.File = "no list names an installed application; " + id + " declares the type" } return h } // SetDefault makes an installed application the default for a type, through xdg-mime: it writes // the person's own list, which the mesh never writes, and which comes before the mesh's. func (m *Machine) SetDefault(mime, id string) (map[string]any, error) { if !desktopID.MatchString(id) { return nil, fmt.Errorf("%q is not a desktop file id (firefox.desktop)", id) } app, ok := m.findApp(id) if !ok { return nil, fmt.Errorf("%s is not installed: no application directory has it (xdg_open_handlers lists what is)", id) } before := m.Handle(mime) if _, err := m.cmd("xdg-mime", "default", id, mime); err != nil { return nil, err } after := m.Handle(mime) answer := map[string]any{ "type": mime, "before": before.XdgOpen, "after": after.XdgOpen, "gtk_programs": after.Gio, "written_to": m.tilde(filepath.Join(m.configHome(), "mimeapps.list")), "note": "the person's list: it outlasts every push and comes before the mesh's " + MeshList, } if after.XdgOpen != id { answer["problem"] = fmt.Sprintf("xdg-mime still answers %q: a list before the person's (%s) names it", after.XdgOpen, after.Decision.File) } if !app.Found { answer["warning"] = fmt.Sprintf("%s runs %s, which is not installed", id, app.Program) } return answer, nil } // InterestingTypes are the types xdg_open_handlers always answers for. var InterestingTypes = []string{ "x-scheme-handler/http", "x-scheme-handler/https", "x-scheme-handler/mailto", "text/html", "inode/directory", "text/plain", "text/markdown", "application/pdf", "image/png", "image/jpeg", "video/mp4", "audio/mpeg", } // DeadDefault is a default naming an application that is not installed. type DeadDefault struct { File string `json:"file"` Type string `json:"type"` ID string `json:"id"` } // deadDefaults are the defaults, in every list, that name an application not installed. func (m *Machine) deadDefaults() []DeadDefault { var out []DeadDefault for _, l := range m.lists() { g := l.Keys.Groups["Default Applications"] for _, t := range sortedKeys(g) { for _, id := range List(g[t]) { if _, ok := m.findApp(id); !ok { out = append(out, DeadDefault{File: l.File, Type: t, ID: id}) } } } } return out } // brokenHandlers are the desktop files that declare types and cannot run. func (m *Machine) brokenHandlers() []Entry { var out []Entry for _, e := range m.apps() { if len(e.MimeTypes) == 0 || e.Hidden { continue } if e.Broken != "" || !e.Found { out = append(out, e) } } return out } // OpenHandlers is xdg_open_handlers. func (m *Machine) OpenHandlers(extra []string) (map[string]any, error) { types := append([]string{}, InterestingTypes...) for _, t := range extra { n, err := Normalise(t) if err != nil { return nil, err } types = append(types, n) } var handlers []Handler seen := map[string]bool{} for _, t := range types { if !seen[t] { seen[t] = true handlers = append(handlers, m.Handle(t)) } } var lists []map[string]string for _, l := range m.lists() { row := map[string]string{"file": l.File, "whose": l.Whose} if l.Broken != "" { row["broken"] = l.Broken } lists = append(lists, row) } return map[string]any{ "handlers": handlers, "lists_in_order": lists, "dead_defaults": m.deadDefaults(), "broken_handlers": m.brokenHandlers(), "about_dead_defaults": "a default naming an application that is not installed is passed over: the next list, or an application declaring the type, answers instead", }, nil } func sortedKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out }