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

302 lines
9.1 KiB
Go

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
}