Merge pull request 'xdg: the account's folders and the machine's default applications' (#50) from feat/xdg-module into main

This commit was merged in pull request #50.
This commit is contained in:
2026-10-05 09:53:30 +00:00
17 changed files with 2352 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
# xdg
The workstations' XDG desktop conventions as a module (novox/hq ADR 0208, ADR 0182): the account's
folders, the default applications, and a view of what starts at login. It claims no seat: nothing
calls "the conventions" as a role. It needs no display; every tool reads files, asks xdg-mime and gio,
or reads `/proc`. It is for the two workstations only.
- **Packages:** `xdg-user-dirs` and `xdg-utils` (`xdg-open`, `xdg-mime`, which the tools use).
`dex`, which runs the autostart entries, is `i3`'s. `desktop-file-utils` and `shared-mime-info` come
as other packages' dependencies and are held as found.
- **No contribution, no session code.** The window manager's `dex --autostart --environment i3` line
is `i3`'s, and stays there.
## What it owns
| path | class (ADR 0182) | from |
|---|---|---|
| `~/.config/user-dirs.dirs` | owned, the found file kept once | [`config/user-dirs.dirs`](config/user-dirs.dirs), three folders from settings |
| `~/.config/user-dirs.locale` | owned, the found file kept once | [`config/user-dirs.locale`](config/user-dirs.locale) |
| `~/.config/user-dirs.conf` | owned, new | [`config/user-dirs.conf`](config/user-dirs.conf): `enabled=False` |
| `/etc/xdg/mimeapps.list` | owned, new | [`config/mimeapps.list`](config/mimeapps.list): the machine's default applications |
## What it does not own, and why
| | whose | why |
|---|---|---|
| `~/.config/mimeapps.list` | the person's, held as found | every program offering "always open with" rewrites it, and so does `xdg-mime default` (below) |
| the folders themselves (`~/Downloads`, …) | the person's, held as found | they hold the person's files. Declaring a directory would make its mode the mesh's (ADR 0182), and on one workstation `~/Desktop` is a file, not a folder |
| `~/.config/autostart/*`, `/etc/xdg/autostart/*` | each application's | an autostart entry belongs to the module of the application it starts (below) |
| `~/.config/xdg-desktop-portal/portals.conf` | `adwaita` | the portal's backends are the theme's business |
| `~/.local/share/applications/*.desktop` | the person's and their installers' | JetBrains Toolbox, Steam, Telegram and the agent write their own launchers and handlers there |
## The folders
`user-dirs.dirs` is owned whole, with the operator's own values. Three folders differ between the
workstations, so they are settings; the rest are the same on both.
| setting | the mesh's layer | the laptop | the desktop machine |
|---|---|---|---|
| `desktop-dir` | `$HOME/Desktop` | as the mesh's | `$HOME/` |
| `publicshare-dir` | `$HOME/Public` | as the mesh's | `$HOME/` |
| `videos-dir` | `$HOME/Videos` | as the mesh's | `$HOME/` |
A value is written into the file as it is, in xdg-user-dirs' format: `"$HOME/yyy"` or `"/yyy"`. `$HOME/`
is the home itself, xdg-user-dirs' way of saying the folder is not wanted. The settings have no
default, so the mesh's layer is set before the first assignment (as `power`'s are). They land in this
one file: the module has no merged file and no contribution, so issue 168 cannot carry them anywhere
else.
**Why `xdg-user-dirs-update` is disabled.** It runs at every login, from its XDG autostart entry, which
`dex` starts. Measured on xdg-user-dirs 0.20 against a copy of the folders:
- enabled, it reassigns a folder it cannot find to the home (`Videos was removed, reassigning VIDEOS to
homedir`), adds a line and creates the folder for every default it knows that the file lacks
(`PROJECTS` arrived this way), and rewrites the file with its own header. Every login would undo
the mesh's file, and every push would undo the login;
- disabled by `enabled=False` in the account's `user-dirs.conf`, it does nothing, even with `--force`.
Only an explicit `--set` still writes.
The package's systemd user unit `xdg-user-dirs.service` is enabled by preset but never runs: it is
wanted by `graphical-session-pre.target`, which the xinit session does not reach. Disabled, it would
do nothing either way, so it is left as found.
`user-dirs.locale` (`en_US` on both) only tells xdg-user-dirs-gtk, which neither workstation has,
which language the folders were named in. It is owned so the account's xdg-user-dirs files are one
set.
## Default applications: the mesh's list is the last one read
**Decision.** The mesh owns `/etc/xdg/mimeapps.list`, the system's list, and never writes the
person's `~/.config/mimeapps.list`. An application reads the person's list first and the system's
after it (the XDG MIME Applications specification), so every choice the person makes wins, and the
mesh's list answers only what the person has not chosen. `xdg_default` gets and sets a default, and
setting writes the person's list through `xdg-mime default`, as any program would.
**Why not own the person's file, or a block in it.** Measured on 2026-10-05 with xdg-utils 1.2.1 and
GLib's `gio`, against copies:
1. **Owned whole** fights the person: Firefox's "make default", a file manager's "always open with" and
`xdg-mime default` all rewrite that file, and the next push would take each choice back.
2. **A mesh block at its start** (`into: block`, as `zsh` and `xorg` do) breaks three ways, because a
mimeapps list is a key file and not a script:
- the block's `[Default Applications]` and the person's are one group written twice. **xdg-mime reads
the first and GLib the last**, so `xdg-open` and every GTK or Electron program answered differently
for the same type;
- `gio mime` (what GTK programs do on "always open with") **rewrote the file as one merged group and
dropped the block's markers**: the next push would find no block and add a second;
- `xdg-mime default` **wrote the person's choice into the first group, inside the mesh's block**,
where the next push removes it.
3. **The system's list** has none of this: nothing but the mesh writes it, each reader reads one group
per file, and a person's choice in their own file is never touched. A default naming an
application that is not installed is passed over, to the next list.
**What the mesh's list holds.** The operator's own defaults, adopted from both workstations' lists of
2026-10-05 (the two were byte-identical), except entries naming an application installed on neither:
- dropped: 55 types for `code-oss.desktop` (no Code-OSS on either machine; plain text opens in
Fleet, from its own declaration), `jetbrains-rider.desktop`, `org.kde.korganizer.desktop`,
`Postman.desktop`, and a Fleet launcher whose name is the id one machine's JetBrains Toolbox
generated (a different one on the other machine);
- kept: Firefox for the web, HTML and PDF; Thunar for folders; ghostwriter for Markdown; Plexamp, Slack,
Telegram and JetBrains Toolbox for their own links; and mpv, mplayer, vlc for MP4.
The person's list keeps every line as found, the dropped ones included. `xdg_check` names them.
## Autostart: listed, not owned
An XDG autostart entry belongs to the module of the application it starts, as `picom` does: picom's
package entry is its one starter. A future `nextcloud-client` module owns `Nextcloud.desktop` (the
client writes it itself), a blueman module beside `bluetooth` owns blueman's, and Slack's and JetBrains
Toolbox's entries would be their modules'.
This module only says what is there, and what the session does with it.
The session's starter is `i3`'s `dex --autostart --environment i3`. `xdg_autostart` decides each
entry by dex's own rules (dex 0.10):
- a user entry hides the system entry of the same name;
- `Hidden=true` keeps an entry from starting;
- an `OnlyShowIn` without `i3` keeps it from starting, and so does a `NotShowIn` with it;
- a `TryExec` that is not installed keeps it from starting;
- `X-GNOME-Autostart-enabled=false` and `AutostartCondition` are GNOME's, and **dex ignores them**: the
entry still starts.
## Tools
| tool | | what |
|---|---|---|
| `xdg_user_dirs` | r | each folder as written, the path it resolves to, whether it exists as a directory or points at the home, the folder xdg-user-dirs would have made; whether the update may rewrite the file, and from which file that is decided; the locale |
| `xdg_default` | r/a | the default for a MIME type or a URL scheme (`https`, `mailto`): what `xdg-open` uses (xdg-mime) and what GTK programs use (gio), flagged when they differ; which list decided it and which named applications were passed over as not installed; the application and whether its program is installed. `set` makes an installed desktop file the default through `xdg-mime default`, in the person's list |
| `xdg_open_handlers` | r | the same for http, https, mailto, HTML, folders, plain text, Markdown, PDF, PNG, JPEG, MP4, MP3 and any types given; every mimeapps list in reading order and whose it is; every default naming an application that is not installed; every installed handler whose program is missing |
| `xdg_autostart` | r | every autostart entry, the account's and the system's: what it runs and whether that is installed, Hidden, OnlyShowIn/NotShowIn, whether dex starts it (and if not, why), whether the window manager's configuration starts the same program too, and how many of the account's processes run it now |
| `xdg_check` | r | the folders file is the mesh's, every folder it names exists, the update cannot rewrite it; the mesh's list is in place, every list parses with no group or key written twice, no default names a missing application, no handler's program is missing; no program has two starters at login, every program dex starts is installed |
From `/proc` only the account's own processes are read, and of each only its command line and name.
## What it leaves found
The person's `~/.config/mimeapps.list`; the folders; every autostart entry; every desktop file under
`~/.local/share/applications`, including its `mimeinfo.cache`; `/etc/xdg/user-dirs.conf` and
`user-dirs.defaults` (the package's); and the `xdg-user-dirs.service` user unit.
## The one-off migration (ADR 0182)
**Before the first assignment,** set the three settings on the mesh's layer (`$HOME/Desktop`,
`$HOME/Public`, `$HOME/Videos`) and on the desktop machine's layer (`$HOME/` for all three).
**Once `xdg` is assigned, on the laptop:**
1. Delete `~/.local/share/applications/mimeapps.list` and `~/.local/share/applications/defaults.list`.
Both are links the retired predecessor made into its own tree, which is gone; they point at nothing.
2. `~/.config/autostart/slack.desktop` is the predecessor's too (its comment says so). It still starts
Slack, and is left until a Slack module owns it.
**On the desktop machine:** nothing to remove. `~/Desktop` is a text file, not a folder, which is why
`desktop-dir` is the home there; move it aside and change the setting if a Desktop folder is wanted.
## What changes when it is assigned
| | the laptop | the desktop machine |
|---|---|---|
| packages | none (both present) | the same |
| `user-dirs.dirs` | the found file kept once, then the module's: the same eight folders, and `PROJECTS` becomes `~/projects` instead of the home | the found file kept once, then the module's: the same eight folders, and a `PROJECTS` line (`~/projects`) where there was none |
| `user-dirs.conf` | new: the update stops rewriting the file at login | new: the update, which `dex` starts here since `i3` installed it, would otherwise have added `PROJECTS` and created `~/Projects` at the next login |
| `/etc/xdg/mimeapps.list` | new; changes no answer today, because the person's list says the same | the same |
| running programs, the person's list, autostart | nothing | nothing |
**The one visible change is `PROJECTS`:** `xdg-user-dir PROJECTS` answers `~/projects`, the folder
where the operator's repositories are, on both machines. GLib knows only the eight older folders, so
GTK programs see no change at all.
## What `xdg_check` will say at first
Measured on the laptop before assignment, and expected on both: the defaults naming applications that
are not installed (the 55 Code-OSS types and the four others above) fail "every default names an
installed application", until the person removes them from their list or installs the editor. On the
laptop, the predecessor's two dead links fail "the mimeapps lists are sane" until migration step 1. It
also showed that `audio/mpeg` opens in Audacity from `xdg-open` and in mpv from GTK programs, because
no list names a default for it: one `xdg_default` with `set` settles it.
## Blockers
- The three settings must be set before the first assignment (above).
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"bufio"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
)
// Autostart is one XDG autostart entry, and what the session does with it.
type Autostart struct {
Entry
Scope string `json:"scope"` // user or system
// Overrides names the system entry of the same name this user entry hides; OverriddenBy the
// user entry hiding this system one.
Overrides string `json:"overrides,omitempty"`
OverriddenBy string `json:"overridden_by,omitempty"`
DexStarts bool `json:"dex_starts"`
Why string `json:"why"`
AlsoStarted string `json:"also_started_by,omitempty"`
Running int `json:"running"`
}
// dexDecides says whether `dex --autostart --environment <env>` starts an entry, by dex's own
// rules (dex 0.10): an entry of the same name in an earlier directory wins; Hidden=true, an
// OnlyShowIn without the environment, a NotShowIn with it, a TryExec that is not there, or a type
// other than Application keep it from starting. X-GNOME-Autostart-enabled and AutostartCondition
// are GNOME's, and dex does not read them.
func (m *Machine) dexDecides(a *Autostart, env string) {
switch {
case a.OverriddenBy != "":
a.Why = "hidden by the user entry " + a.OverriddenBy
case a.Broken != "":
a.Why = a.Broken
case a.Type != "" && a.Type != "Application":
a.Why = "type " + a.Type + ", not Application"
case a.Hidden:
a.Why = "Hidden=true"
case len(a.OnlyShow) > 0 && !contains(a.OnlyShow, env):
a.Why = "OnlyShowIn " + strings.Join(a.OnlyShow, ";") + " does not name " + env
case contains(a.NotShow, env):
a.Why = "NotShowIn names " + env
case a.TryExec != "" && !m.tryExecFound(a.TryExec):
a.Why = "TryExec " + a.TryExec + " is not installed"
default:
a.DexStarts = true
a.Why = "started at every login"
if !a.Found {
a.Why = "started at every login, and fails: " + a.Program + " is not installed"
}
if strings.EqualFold(a.GnomeEnabled, "false") {
a.Why += "; X-GNOME-Autostart-enabled=false does not stop dex (use Hidden=true)"
}
}
}
func (m *Machine) tryExecFound(p string) bool {
_, ok := m.executable(p)
return ok
}
func contains(list []string, s string) bool {
for _, x := range list {
if strings.EqualFold(x, s) {
return true
}
}
return false
}
// autostartDirs are the account's directory, then the system's, as dex reads them.
func (m *Machine) autostartDirs() []struct{ dir, scope string } {
out := []struct{ dir, scope string }{{filepath.Join(m.configHome(), "autostart"), "user"}}
for _, d := range m.configDirs() {
out = append(out, struct{ dir, scope string }{filepath.Join(d, "autostart"), "system"})
}
return out
}
// Autostarts lists every entry, user and system, with dex's decision for env.
func (m *Machine) Autostarts(env string) []Autostart {
var out []Autostart
first := map[string]int{}
procs := m.processes()
starts := m.windowManagerStarts()
for _, d := range m.autostartDirs() {
entries, _ := os.ReadDir(m.p(d.dir))
for _, de := range entries {
if !strings.HasSuffix(de.Name(), ".desktop") {
continue
}
a := Autostart{Entry: m.readEntry(filepath.Join(d.dir, de.Name())), Scope: d.scope}
if i, seen := first[de.Name()]; seen {
a.OverriddenBy = out[i].File
out[i].Overrides = a.File
} else {
first[de.Name()] = len(out)
}
out = append(out, a)
}
}
for i := range out {
a := &out[i]
m.dexDecides(a, env)
a.Running = procs.count(a.Program)
if by, ok := starts[filepath.Base(a.Program)]; ok && a.Program != "" {
a.AlsoStarted = by
}
}
return out
}
// procs are the account's processes: each one's first two words, base names only, and its name.
type procs [][3]string
// processes reads the account's own processes from /proc; nothing but the command line and the
// process's name is read.
func (m *Machine) processes() procs {
var out procs
entries, _ := os.ReadDir(m.p("/proc"))
self := strconv.Itoa(os.Getpid())
for _, e := range entries {
if _, err := strconv.Atoi(e.Name()); err != nil || e.Name() == self {
continue
}
dir := m.p(filepath.Join("/proc", e.Name()))
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "cmdline"))
if err != nil || len(raw) == 0 {
continue
}
words := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
if len(words) == 1 {
// Chromium and Electron rewrite their command line as one string, words joined by spaces.
words = strings.Fields(words[0])
}
if len(words) == 0 {
continue
}
var p [3]string
if comm, err := os.ReadFile(filepath.Join(dir, "comm")); err == nil {
p[2] = strings.TrimSpace(string(comm))
}
p[0] = filepath.Base(words[0])
if len(words) > 1 {
p[1] = filepath.Base(words[1])
}
out = append(out, p)
}
return out
}
// count is how many processes run a program: as their command, or as the script an interpreter
// runs (a Python applet is "python3 /usr/bin/applet").
func (ps procs) count(program string) int {
if program == "" {
return 0
}
name := filepath.Base(program)
n := 0
for _, p := range ps {
// The kernel keeps a process's name to 15 bytes.
if p[0] == name || p[1] == name && isInterpreter(p[0]) || p[2] != "" && p[2] == truncate(name, 15) {
n++
}
}
return n
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func isInterpreter(cmd string) bool {
for _, i := range []string{"python", "perl", "sh", "bash", "node", "ruby"} {
if strings.HasPrefix(cmd, i) {
return true
}
}
return false
}
// windowManagerStarts are the programs the window manager's configuration starts itself, by base
// name, with the line that does it. Read only: the configuration is the window manager's module's.
func (m *Machine) windowManagerStarts() map[string]string {
out := map[string]string{}
files := []string{filepath.Join(m.configHome(), "i3", "config")}
more, _ := filepath.Glob(m.p(filepath.Join(m.configHome(), "i3", "config.d", "*.conf")))
sort.Strings(more)
for _, f := range more {
files = append(files, strings.TrimPrefix(f, m.Root))
}
for _, f := range files {
h, err := os.Open(m.p(f))
if err != nil {
continue
}
sc := bufio.NewScanner(h)
n := 0
for sc.Scan() {
n++
words := strings.Fields(sc.Text())
if len(words) < 2 || words[0] != "exec" && words[0] != "exec_always" {
continue
}
rest := words[1:]
for len(rest) > 0 && strings.HasPrefix(rest[0], "--") {
rest = rest[1:]
}
if len(rest) == 0 {
continue
}
prog := filepath.Base(Program(strings.Join(rest, " ")))
if prog == "dex" {
continue
}
out[prog] = m.tilde(f) + ":" + strconv.Itoa(n)
}
h.Close()
}
return out
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Check is one line of xdg_check's answer.
type Check struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// Check is xdg_check: whether the account's XDG conventions are as the module declares them, and
// what is wrong in what it holds as found.
func (m *Machine) Check(env string) map[string]any {
var checks []Check
add := func(name string, ok bool, detail string) { checks = append(checks, Check{name, ok, detail}) }
// The folders.
u := m.UserDirs()
add("user-dirs.dirs is the mesh's", u.Meshs, map[bool]string{
true: u.File + " carries the module's header",
false: u.File + " has no header of module xdg: the module is not assigned here, or the file was rewritten since the last push",
}[u.Meshs])
var missing []string
for _, d := range u.Dirs {
if !d.Home && !d.Exists {
missing = append(missing, fmt.Sprintf("%s → %s (%s)", d.Name, d.Path, d.Kind))
}
}
add("every folder it names exists", len(missing) == 0, orNone(missing, "each is a directory, or the home"))
add("xdg-user-dirs-update leaves it alone", !u.Update.Enabled, "enabled is "+fmt.Sprint(u.Update.Enabled)+", from "+u.Update.DecidedBy)
// The default applications.
var listProblems []string
meshListFound := false
for _, l := range m.lists() {
if l.File == MeshList {
meshListFound = true
}
switch {
case l.Broken != "":
listProblems = append(listProblems, l.File+": "+l.Broken)
case len(l.Keys.DupGroups) > 0:
listProblems = append(listProblems, fmt.Sprintf("%s: the group %s is written twice, and GLib and xdg-mime then answer differently", l.File, strings.Join(l.Keys.DupGroups, ", ")))
case len(l.Keys.DupKeys) > 0:
listProblems = append(listProblems, fmt.Sprintf("%s: %s written twice, and GLib and xdg-mime then answer differently", l.File, strings.Join(l.Keys.DupKeys, ", ")))
case len(l.Keys.Unparsable) > 0:
listProblems = append(listProblems, fmt.Sprintf("%s: lines %v are not key=value under a group", l.File, l.Keys.Unparsable))
}
if m.deprecated(l.File) && strings.HasPrefix(l.File, "~/") {
listProblems = append(listProblems, l.File+": a list in the deprecated place, which nothing current writes (a predecessor's; remove it once)")
}
}
for _, f := range []string{filepath.Join(m.dataHome(), "applications", "defaults.list")} {
if m.kind(f) != "absent" || m.isLink(f) {
listProblems = append(listProblems, m.tilde(f)+": the long-obsolete defaults.list, which nothing reads any more; remove it once")
}
}
add("the mesh's default applications are in place", meshListFound, MeshList)
add("the mimeapps lists are sane", len(listProblems) == 0, orNone(listProblems, "each parses, with no group or key written twice"))
var dead []string
for _, d := range m.deadDefaults() {
dead = append(dead, fmt.Sprintf("%s: %s=%s", d.File, d.Type, d.ID))
}
add("every default names an installed application", len(dead) == 0, orNone(dead, "all installed"))
var broken []string
for _, e := range m.brokenHandlers() {
why := e.Broken
if why == "" {
why = e.Program + " is not installed"
}
broken = append(broken, fmt.Sprintf("%s (%s): %s", e.ID, e.File, why))
}
add("no handler's program is missing", len(broken) == 0, orNone(broken, "every application declaring a type can run"))
// Autostart.
starts := m.Autostarts(env)
byProgram := map[string][]string{}
var twice, failing []string
for _, a := range starts {
if !a.DexStarts {
continue
}
byProgram[filepath.Base(a.Program)] = append(byProgram[filepath.Base(a.Program)], a.File)
if a.AlsoStarted != "" {
twice = append(twice, fmt.Sprintf("%s: by %s and by %s", a.Program, a.File, a.AlsoStarted))
}
if !a.Found {
failing = append(failing, fmt.Sprintf("%s: %s is not installed", a.File, a.Program))
}
}
for _, p := range sortedKeys(byProgram) {
if len(byProgram[p]) > 1 {
twice = append(twice, fmt.Sprintf("%s: by %s", p, strings.Join(byProgram[p], " and ")))
}
}
sort.Strings(twice)
add("no program is started twice at login", len(twice) == 0, orNone(twice, "each autostarted program has one starter"))
add("every program dex starts is installed", len(failing) == 0, orNone(failing, "all installed"))
failingN := 0
for _, c := range checks {
if !c.OK {
failingN++
}
}
return map[string]any{"checks": checks, "failing": failingN}
}
func (m *Machine) isLink(path string) bool {
_, err := os.Lstat(m.p(path))
return err == nil
}
func orNone(items []string, fine string) string {
if len(items) == 0 {
return fine
}
return strings.Join(items, "; ")
}
+234
View File
@@ -0,0 +1,234 @@
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
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
callTimeout = 5 * time.Second
outputCap = 64 << 10
)
// Runner runs a command with an environment and answers its standard output; a failure carries
// what the command said on standard error. Injected, so the tools are tested without a machine.
type Runner func(ctx context.Context, env []string, name string, args ...string) (string, error)
// Machine is everything the tools read. Root is "" on the machine and a fake tree in tests; every
// other path is as the machine names it.
type Machine struct {
Root string
Home string
UID int
Desktop string // the session's XDG_CURRENT_DESKTOP, lower case
Run Runner
Timeout time.Duration
}
// NewMachine is the real machine, as the operator's account the runtime serves the tools as.
func NewMachine() *Machine {
home := os.Getenv("MESH_OPERATOR_HOME")
if home == "" {
home, _ = os.UserHomeDir()
}
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
if desktop == "" {
// The window manager's session says i3; the runtime is not in the session, so it says nothing.
desktop = "i3"
}
return &Machine{Home: home, UID: os.Getuid(), Desktop: desktop, Run: run, Timeout: callTimeout}
}
// run is the machine's Runner: the command in its own process group, killed with everything it
// started at the deadline, each stream cut at outputCap.
func run(ctx context.Context, env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
return "", err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return out.String(), fmt.Errorf("%s did not finish in time", name)
}
if err != nil {
said := strings.TrimSpace(errb.String())
if said == "" {
said = strings.TrimSpace(out.String())
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return out.String(), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), said)
}
return out.String(), fmt.Errorf("%s: %v", name, err)
}
return out.String(), nil
}
// capped keeps the first outputCap bytes written to it.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := outputCap - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// cmd runs one command, bounded, with the account's XDG environment.
func (m *Machine) cmd(name string, args ...string) (string, error) {
t := m.Timeout
if t == 0 {
t = callTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), t)
defer cancel()
return m.Run(ctx, m.env(), name, args...)
}
// env is what xdg-mime and gio are given: the account's home and the session's data directories,
// so they look where the session looks. Nothing else of the runtime's environment is passed on.
func (m *Machine) env() []string {
return []string{
"HOME=" + m.Home,
"PATH=" + strings.Join(m.pathDirs(), ":"),
"LANG=C.UTF-8",
"XDG_CONFIG_HOME=" + m.configHome(),
"XDG_DATA_HOME=" + m.dataHome(),
"XDG_DATA_DIRS=" + strings.Join(m.dataDirs(), ":"),
"XDG_CURRENT_DESKTOP=" + m.Desktop,
}
}
// p is a machine path inside Root.
func (m *Machine) p(path string) string { return filepath.Join(m.Root, path) }
func (m *Machine) configHome() string { return filepath.Join(m.Home, ".config") }
func (m *Machine) dataHome() string { return filepath.Join(m.Home, ".local", "share") }
// configDirs are the system's configuration directories: XDG_CONFIG_DIRS is unset in both
// workstations' sessions or says only this.
func (m *Machine) configDirs() []string { return []string{"/etc/xdg"} }
// dataDirs are the session's XDG_DATA_DIRS on the workstations, those that exist.
func (m *Machine) dataDirs() []string {
candidates := []string{
filepath.Join(m.Home, ".local", "share", "flatpak", "exports", "share"),
"/var/lib/flatpak/exports/share",
"/usr/local/share",
"/usr/share",
"/var/lib/snapd/desktop",
}
var out []string
for _, d := range candidates {
if d == "/usr/share" || d == "/usr/local/share" || m.isDir(d) {
out = append(out, d)
}
}
return out
}
// pathDirs is where a program is looked for: the account's own bin, then the system's.
func (m *Machine) pathDirs() []string {
return []string{filepath.Join(m.Home, ".local", "bin"), "/usr/local/sbin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/lib/flatpak/bin"}
}
func (m *Machine) isDir(path string) bool {
info, err := os.Stat(m.p(path))
return err == nil && info.IsDir()
}
func (m *Machine) read(path string) (string, bool) {
b, err := os.ReadFile(m.p(path))
if err != nil {
return "", false
}
return string(b), true
}
// executable is whether a program is there to run: an absolute path that is an executable file,
// or a name found on the path.
func (m *Machine) executable(program string) (string, bool) {
if program == "" {
return "", false
}
if strings.Contains(program, "/") {
return program, isExecutable(m.p(program))
}
for _, d := range m.pathDirs() {
full := filepath.Join(d, program)
if isExecutable(m.p(full)) {
return full, true
}
}
return program, false
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir() && info.Mode()&0o111 != 0
}
// tilde writes a path under the home as ~/…, which is how the README and the person name it.
func (m *Machine) tilde(path string) string {
if path == m.Home {
return "~"
}
if strings.HasPrefix(path, m.Home+"/") {
return "~/" + strings.TrimPrefix(path, m.Home+"/")
}
return path
}
+429
View File
@@ -0,0 +1,429 @@
package main
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
const home = "/home/op"
// fake is a machine with these files under a temporary root; a value starting with "->" is a
// symbolic link to the rest, and one starting with "#!" is an executable.
func fake(t *testing.T, files map[string]string) *Machine {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
switch {
case strings.HasPrefix(c, "->"):
if err := os.Symlink(strings.TrimPrefix(c, "->"), full); err != nil {
t.Fatal(err)
}
case c == "<dir>":
if err := os.MkdirAll(full, 0o755); err != nil {
t.Fatal(err)
}
default:
mode := os.FileMode(0o644)
if strings.HasPrefix(c, "#!") {
mode = 0o755
}
if err := os.WriteFile(full, []byte(c), mode); err != nil {
t.Fatal(err)
}
}
}
return &Machine{Root: root, Home: home, UID: os.Getuid(), Desktop: "i3", Timeout: time.Second,
Run: func(context.Context, []string, string, ...string) (string, error) { return "", nil }}
}
func app(name, exec, mime string) string {
s := "[Desktop Entry]\nType=Application\nName=" + name + "\nExec=" + exec + "\n"
if mime != "" {
s += "MimeType=" + mime + "\n"
}
return s
}
// The desktop's folders as found on 2026-10-05, in the module's file with that machine's settings.
func desktopUserDirs(t *testing.T) string {
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.dirs"))
if err != nil {
t.Fatal(err)
}
r := strings.NewReplacer("${setting:desktop-dir}", "$HOME/", "${setting:publicshare-dir}", "$HOME/", "${setting:videos-dir}", "$HOME/")
return r.Replace(string(raw))
}
func TestTheFoldersAreReadAsXdgUserDirResolvesThem(t *testing.T) {
conf, _ := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.conf"))
m := fake(t, map[string]string{
home + "/.config/user-dirs.dirs": desktopUserDirs(t),
home + "/.config/user-dirs.conf": string(conf),
home + "/.config/user-dirs.locale": "en_US\n",
"/etc/xdg/user-dirs.conf": "enabled=True\n",
"/etc/xdg/user-dirs.defaults": "# c\nDESKTOP=Desktop\nDOWNLOAD=Downloads\nPROJECTS=Projects\nNEWTHING=New\n",
home + "/Desktop": "a file where the folder would be\n",
home + "/Downloads": "<dir>",
home + "/projects": "<dir>",
})
a := m.UserDirs()
if !a.Meshs || a.Locale != "en_US" || a.Update.Enabled || a.Update.DecidedBy != "~/.config/user-dirs.conf" {
t.Fatalf("%+v", a)
}
got := map[string]UserDir{}
for _, d := range a.Dirs {
got[d.Name] = d
}
if len(a.Dirs) != 9 {
t.Fatalf("%d folders: %+v", len(a.Dirs), a.Dirs)
}
if d := got["DESKTOP"]; !d.Home || d.Path != "~" || d.Default != "~/Desktop" {
t.Errorf("DESKTOP: %+v", d)
}
if d := got["DOWNLOAD"]; d.Home || !d.Exists || d.Path != "~/Downloads" {
t.Errorf("DOWNLOAD: %+v", d)
}
if d := got["PROJECTS"]; d.Path != "~/projects" || !d.Exists || d.Default != "~/Projects" {
t.Errorf("PROJECTS: %+v", d)
}
if d := got["MUSIC"]; d.Exists || d.Kind != "absent" {
t.Errorf("MUSIC: %+v", d)
}
if !reflect.DeepEqual(a.Missing, []string{"NEWTHING"}) {
t.Errorf("defaults without a line: %v", a.Missing)
}
}
func TestAnUpdateLeftEnabledIsSaid(t *testing.T) {
m := fake(t, map[string]string{"/etc/xdg/user-dirs.conf": "# c\nenabled=True\nfilename_encoding=UTF-8\n"})
a := m.UserDirs()
if !a.Update.Enabled || a.Update.DecidedBy != "/etc/xdg/user-dirs.conf" || !strings.Contains(a.Update.Note, "rewrites the file") {
t.Fatalf("%+v", a.Update)
}
}
func TestATypeIsAMimeTypeOrAScheme(t *testing.T) {
for in, want := range map[string]string{
"https": "x-scheme-handler/https", "mailto:": "x-scheme-handler/mailto", "http://": "x-scheme-handler/http",
"text/html": "text/html", "x-scheme-handler/tg": "x-scheme-handler/tg", "application/xhtml+xml": "application/xhtml+xml",
} {
if got, err := Normalise(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, bad := range []string{"", "text/html; rm -rf", "../x", "Text Html"} {
if _, err := Normalise(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestTheProgramIsFoundBehindLaunchers(t *testing.T) {
// Exec lines found on the workstations.
for exec, want := range map[string]string{
`env APPIMAGELAUNCHER_DISABLE=1 DESKTOPINTEGRATION=0 /usr/bin/Plexamp.AppImage --disable-seccomp-filter-sandbox`: "/usr/bin/Plexamp.AppImage",
`sh -c "/opt/forticlient/gui/FortiClient >/dev/null 2>&1"`: "/opt/forticlient/gui/FortiClient",
`zsh -c 'dockly'`: "dockly",
`"/opt/claude-code/bin/claude" --handle-uri %u`: "/opt/claude-code/bin/claude",
`env LIBGL_ALWAYS_SOFTWARE=1 Telegram -- %U`: "Telegram",
`/usr/bin/slack --gtk-version=3 -s %U`: "/usr/bin/slack",
`"/path with space/Fleet" %u`: "/path with space/Fleet",
`steam steam://rungameid/427520`: "steam",
`xterm -fa monospace -fs 11 -e glow -p %f`: "xterm",
``: "",
} {
if got := Program(exec); got != want {
t.Errorf("%s: %q, want %q", exec, got, want)
}
}
}
func TestADuplicateGroupOrKeyIsReportedNotMerged(t *testing.T) {
k := ParseKeyFile("# >>> mesh >>>\n[Default Applications]\nx=a.desktop\n# <<<\n\n[Default Applications]\nx=b.desktop\ny=c\n[Added Associations]\nz=d;\nstray\n")
if !reflect.DeepEqual(k.DupGroups, []string{"Default Applications"}) || !reflect.DeepEqual(k.DupKeys, []string{"Default Applications: x"}) {
t.Fatalf("%+v", k)
}
if k.Groups["Default Applications"]["x"] != "a.desktop" || k.Groups["Default Applications"]["y"] != "c" || len(k.Unparsable) != 1 {
t.Fatalf("%+v", k)
}
}
// A machine with the mesh's list, the person's list, and some applications.
func mimeMachine(t *testing.T, extra map[string]string) *Machine {
mesh, err := os.ReadFile(filepath.Join("..", "..", "config", "mimeapps.list"))
if err != nil {
t.Fatal(err)
}
files := map[string]string{
MeshList: string(mesh),
home + "/.config/mimeapps.list": "[Default Applications]\ntext/plain=code-oss.desktop;\ninode/directory=xplr.desktop\nx-scheme-handler/https=gone.desktop;chromium.desktop;\n",
"/usr/share/applications/firefox.desktop": app("Firefox", "/usr/lib/firefox/firefox %u", "text/html;x-scheme-handler/https;"),
"/usr/share/applications/chromium.desktop": app("Chromium", "/usr/bin/chromium %U", "x-scheme-handler/https;"),
"/usr/share/applications/thunar.desktop": app("Thunar", "thunar %F", "inode/directory;"),
home + "/.local/share/applications/xplr.desktop": app("xplr", "xplr", "inode/directory;"),
home + "/.local/share/applications/fleet.desktop": app("Fleet", `"/home/op/fleet/bin/Fleet" %u`, "text/plain;"),
"/usr/lib/firefox/firefox": "#!/bin/sh\n",
"/usr/bin/chromium": "#!/bin/sh\n",
"/usr/bin/thunar": "#!/bin/sh\n",
}
for k, v := range extra {
files[k] = v
}
return fake(t, files)
}
func TestTheDefaultIsDecidedInTheSpecificationsOrder(t *testing.T) {
m := mimeMachine(t, nil)
// The person's list names an application that is gone, then one that is installed.
if d := m.Decide("x-scheme-handler/https"); d.ID != "chromium.desktop" || d.File != "~/.config/mimeapps.list" ||
!reflect.DeepEqual(d.Skipped, []string{"gone.desktop (~/.config/mimeapps.list): not installed"}) {
t.Fatalf("%+v", d)
}
// The person's choice wins over the mesh's.
if d := m.Decide("inode/directory"); d.ID != "xplr.desktop" {
t.Fatalf("%+v", d)
}
// What the person has not chosen, the mesh's list answers.
if d := m.Decide("text/html"); d.ID != "firefox.desktop" || d.File != MeshList {
t.Fatalf("%+v", d)
}
// Nothing installed is named: no list decides.
if d := m.Decide("text/plain"); d.ID != "" || len(d.Skipped) != 1 {
t.Fatalf("%+v", d)
}
// The desktop's own list comes first of all.
m2 := mimeMachine(t, map[string]string{home + "/.config/i3-mimeapps.list": "[Default Applications]\ninode/directory=thunar.desktop\n"})
if d := m2.Decide("inode/directory"); d.ID != "thunar.desktop" || d.File != "~/.config/i3-mimeapps.list" {
t.Fatalf("%+v", d)
}
}
// recorded answers xdg-mime and gio as they answered on the laptop on 2026-10-05.
func recorded(calls *[]string) Runner {
return func(_ context.Context, env []string, name string, args ...string) (string, error) {
*calls = append(*calls, name+" "+strings.Join(args, " "))
key := name + " " + strings.Join(args, " ")
switch key {
case "xdg-mime query default audio/mpeg":
return "audacity.desktop\n", nil
case "gio mime audio/mpeg":
return "Default application for “audio/mpeg”: mpv.desktop\nRegistered applications:\n\tmpv.desktop\n\taudacity.desktop\nRecommended applications:\n\tmpv.desktop\n\taudacity.desktop\n", nil
case "xdg-mime query default x-scheme-handler/https":
return "chromium.desktop\n", nil
case "gio mime x-scheme-handler/https":
return "Default application for “x-scheme-handler/https”: chromium.desktop\nRegistered applications:\n\tchromium.desktop\n", nil
case "xdg-mime query default x-scheme-handler/nothing-here":
return "", nil
case "gio mime x-scheme-handler/nothing-here":
return "No default applications for “x-scheme-handler/nothing-here”\n", nil
}
return "", nil
}
}
func TestTwoReadersThatDisagreeAreSaid(t *testing.T) {
var calls []string
m := mimeMachine(t, map[string]string{
"/usr/share/applications/audacity.desktop": app("Audacity", "audacity %F", "audio/mpeg;"),
"/usr/share/applications/mpv.desktop": app("mpv", "mpv -- %U", "audio/mpeg;video/mp4;"),
"/usr/bin/audacity": "#!/bin/sh\n",
})
m.Run = recorded(&calls)
h := m.Handle("audio/mpeg")
if h.XdgOpen != "audacity.desktop" || h.Gio != "mpv.desktop" || !h.Disagree || h.App == nil || !h.App.Found {
t.Fatalf("%+v", h)
}
if !strings.Contains(h.Decision.File, "no list names") {
t.Fatalf("%+v", h.Decision)
}
none := m.Handle("x-scheme-handler/nothing-here")
if none.XdgOpen != "" || none.Gio != "" || none.Problem != "nothing opens it" {
t.Fatalf("%+v", none)
}
}
func TestTheEnvironmentGivenIsTheAccountsXdgOnly(t *testing.T) {
var env []string
m := mimeMachine(t, nil)
m.Run = func(_ context.Context, e []string, _ string, _ ...string) (string, error) { env = e; return "", nil }
m.Handle("text/html")
joined := strings.Join(env, "\n")
for _, want := range []string{"HOME=" + home, "XDG_CONFIG_HOME=" + home + "/.config", "XDG_CURRENT_DESKTOP=i3", "LANG=C.UTF-8"} {
if !strings.Contains(joined, want) {
t.Errorf("lacks %s: %v", want, env)
}
}
if len(env) != 7 {
t.Errorf("only the seven XDG words are passed on: %v", env)
}
}
func TestSettingADefaultWritesThePersonsListThroughXdgMime(t *testing.T) {
var calls []string
m := mimeMachine(t, nil)
m.Run = recorded(&calls)
a, err := m.SetDefault("x-scheme-handler/https", "chromium.desktop")
if err != nil {
t.Fatal(err)
}
if !contains(calls, "xdg-mime default chromium.desktop x-scheme-handler/https") {
t.Fatalf("%v", calls)
}
if a["written_to"] != "~/.config/mimeapps.list" || a["after"] != "chromium.desktop" || a["problem"] != nil {
t.Fatalf("%v", a)
}
for _, refused := range []string{"gone.desktop", "../../etc/passwd", "firefox", "x.desktop; rm"} {
calls = nil
if _, err := m.SetDefault("text/html", refused); err == nil || len(calls) != 0 {
t.Errorf("%s: %v %v", refused, err, calls)
}
}
}
func TestDeadDefaultsBrokenHandlersAndListsAreFound(t *testing.T) {
m := mimeMachine(t, map[string]string{
home + "/.local/share/applications/mimeapps.list": "->/home/op/.retired/dotfiles/mimeapps.list",
home + "/.local/share/applications/defaults.list": "->/home/op/.retired/dotfiles/mimeapps.list",
home + "/.local/share/applications/fleet-old.desktop": app("Fleet", "/home/op/gone/Fleet %u", "x-scheme-handler/fleet;"),
})
dead := m.deadDefaults()
var ids []string
for _, d := range dead {
// The fake installs none of the mesh's list's applications but firefox and thunar.
if d.File == "~/.config/mimeapps.list" {
ids = append(ids, d.Type+"="+d.ID)
}
}
if !reflect.DeepEqual(ids, []string{"text/plain=code-oss.desktop", "x-scheme-handler/https=gone.desktop"}) {
t.Fatalf("%v", ids)
}
var broken []string
for _, e := range m.brokenHandlers() {
broken = append(broken, e.ID)
}
// xplr's program is not installed in the fake, nor fleet's.
if !reflect.DeepEqual(broken, []string{"fleet-old.desktop", "fleet.desktop", "xplr.desktop"}) {
t.Fatalf("%v", broken)
}
c := m.Check("i3")
checks := map[string]Check{}
for _, x := range c["checks"].([]Check) {
checks[x.Name] = x
}
if s := checks["the mimeapps lists are sane"]; s.OK || !strings.Contains(s.Detail, "a link to nothing") || !strings.Contains(s.Detail, "defaults.list") {
t.Fatalf("%+v", s)
}
if s := checks["the mesh's default applications are in place"]; !s.OK {
t.Fatalf("%+v", s)
}
}
func TestAMeshBlockInThePersonsListIsWhatTheCheckRefuses(t *testing.T) {
// Why the module does not write a block into ~/.config/mimeapps.list: GLib reads the last of two
// groups, xdg-mime the first, and either rewrites the file around the block.
m := mimeMachine(t, map[string]string{
home + "/.config/mimeapps.list": "# >>> mesh >>>\n[Default Applications]\ntext/html=firefox.desktop\n# <<< mesh <<<\n[Default Applications]\ntext/html=chromium.desktop\n",
})
c := m.Check("i3")
for _, x := range c["checks"].([]Check) {
if x.Name == "the mimeapps lists are sane" && (x.OK || !strings.Contains(x.Detail, "written twice")) {
t.Fatalf("%+v", x)
}
}
}
func autostartMachine(t *testing.T) *Machine {
return fake(t, map[string]string{
home + "/.config/autostart/slack.desktop": app("Slack", "/usr/bin/slack -s %U", "") + "Hidden=false\n",
home + "/.config/autostart/blueman.desktop": app("Blueman", "blueman-applet", "") + "Hidden=true\n",
home + "/.config/autostart/nextcloud.desktop": app("Nextcloud", `"/usr/bin/nextcloud" --background`, "") + "X-GNOME-Autostart-enabled=false\n",
"/etc/xdg/autostart/blueman.desktop": app("Blueman", "blueman-applet", ""),
"/etc/xdg/autostart/picom.desktop": app("picom", "picom", "") + "TryExec=picom\n",
"/etc/xdg/autostart/nm-applet.desktop": app("nm", "nm-applet", "") + "NotShowIn=KDE;GNOME;\n",
"/etc/xdg/autostart/gnome-keyring.desktop": app("keyring", "/usr/bin/gnome-keyring-daemon --start", "") + "OnlyShowIn=GNOME;Unity;\n",
"/etc/xdg/autostart/geoclue.desktop": app("geoclue", "/usr/lib/geoclue-2.0/demos/agent", "") + "NotShowIn=i3;\n",
"/etc/xdg/autostart/print.desktop": app("print", "system-config-printer-applet", "") + "TryExec=system-config-printer-applet\n",
"/etc/xdg/autostart/polychromatic.desktop": app("poly", "polychromatic-helper --autostart", ""),
"/etc/xdg/autostart/slack-too.desktop": app("Slack", "/usr/bin/slack", ""),
home + "/.config/i3/config": "exec --no-startup-id dex --autostart --environment i3\nexec --no-startup-id picom\n",
home + "/.config/i3/config.d/90-x.conf": "exec_always --no-startup-id polychromatic-tray-applet\n",
"/usr/bin/slack": "#!/bin/sh\n",
"/usr/bin/picom": "#!/bin/sh\n",
"/usr/bin/nm-applet": "#!/bin/sh\n",
"/usr/bin/nextcloud": "#!/bin/sh\n",
"/usr/bin/polychromatic-helper": "#!/bin/sh\n",
"/proc/101/cmdline": "/usr/lib/slack/slack --type=renderer --enable-crash-reporter",
"/proc/101/comm": "slack\n",
"/proc/102/cmdline": "picom\x00",
"/proc/103/cmdline": "python3\x00/usr/bin/polychromatic-helper\x00--autostart\x00",
"/proc/104/cmdline": "nextcloud-sync-helper\x00",
"/proc/self/cmdline": "not a process\x00",
})
}
func TestAutostartIsDecidedAsDexDecides(t *testing.T) {
m := autostartMachine(t)
got := map[string]Autostart{}
for _, a := range m.Autostarts("i3") {
got[a.Scope+"/"+a.ID] = a
}
want := map[string]bool{
"user/slack.desktop": true, "user/blueman.desktop": false, "user/nextcloud.desktop": true,
"system/blueman.desktop": false, "system/picom.desktop": true, "system/nm-applet.desktop": true,
"system/gnome-keyring.desktop": false, "system/geoclue.desktop": false, "system/print.desktop": false,
"system/polychromatic.desktop": true, "system/slack-too.desktop": true,
}
if len(got) != len(want) {
t.Fatalf("%d entries: %v", len(got), got)
}
for k, starts := range want {
if got[k].DexStarts != starts {
t.Errorf("%s: dex starts %v (%s)", k, got[k].DexStarts, got[k].Why)
}
}
if a := got["system/blueman.desktop"]; a.OverriddenBy != "~/.config/autostart/blueman.desktop" || got["user/blueman.desktop"].Overrides != "/etc/xdg/autostart/blueman.desktop" {
t.Errorf("the user's entry hides the system's of the same name: %+v", a)
}
if a := got["user/nextcloud.desktop"]; !strings.Contains(a.Why, "does not stop dex") {
t.Errorf("%s", a.Why)
}
if a := got["system/print.desktop"]; !strings.Contains(a.Why, "TryExec") {
t.Errorf("%s", a.Why)
}
if a := got["system/picom.desktop"]; a.AlsoStarted != "~/.config/i3/config:2" || a.Running != 1 {
t.Errorf("picom: %+v", a)
}
if got["user/slack.desktop"].Running != 1 || got["system/polychromatic.desktop"].Running != 1 || got["user/nextcloud.desktop"].Running != 0 {
t.Errorf("running: slack %d, polychromatic %d, nextcloud %d", got["user/slack.desktop"].Running,
got["system/polychromatic.desktop"].Running, got["user/nextcloud.desktop"].Running)
}
}
func TestTheCheckFindsTwoStartersAndAMissingProgram(t *testing.T) {
m := autostartMachine(t)
checks := map[string]Check{}
for _, c := range m.Check("i3")["checks"].([]Check) {
checks[c.Name] = c
}
twice := checks["no program is started twice at login"]
if twice.OK || !strings.Contains(twice.Detail, "picom: by /etc/xdg/autostart/picom.desktop and by ~/.config/i3/config:2") ||
!strings.Contains(twice.Detail, "slack: by ~/.config/autostart/slack.desktop and /etc/xdg/autostart/slack-too.desktop") {
t.Fatalf("%+v", twice)
}
// blueman's system entry is hidden by the user's, so dex starts nothing that is missing.
if c := checks["every program dex starts is installed"]; !c.OK {
t.Fatalf("%+v", c)
}
if c := checks["user-dirs.dirs is the mesh's"]; c.OK {
t.Fatalf("no file, yet %+v", c)
}
}
+150
View File
@@ -0,0 +1,150 @@
// xdg's Go tools bundle: the account's XDG desktop conventions — its folders, its default
// applications and what starts at login. Served by the node's runtime as the operator account.
//
// None needs the display. Folders, lists and autostart entries are files; xdg-mime and gio answer
// from the same files; whether a program runs is read from /proc. Only xdg_default's `set` changes
// anything, and only the person's own list of defaults.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools(NewMachine())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func text(args map[string]any, name string) (string, error) {
v, ok := args[name]
if !ok || v == nil {
return "", nil
}
s, isText := v.(string)
if !isText {
return "", fmt.Errorf("%s is text", name)
}
return s, nil
}
func tools(m *Machine) []stdio.Tool {
envArg := map[string]any{"type": "string", "description": "the desktop name dex is given (default: the session's, i3)"}
env := func(args map[string]any) (string, error) {
e, err := text(args, "environment")
if err != nil || e != "" {
return e, err
}
return m.Desktop, nil
}
return []stdio.Tool{
{
Name: "xdg_user_dirs",
Description: "The account's folders (Desktop, Downloads, Documents, …) as user-dirs.dirs names them: " +
"each one as written, the path it resolves to, whether it exists as a directory, whether it points " +
"at the home (not wanted), and the folder xdg-user-dirs would have made instead. Also whether " +
"xdg-user-dirs-update may rewrite the file at login, and the locale it was made in.",
Run: func(map[string]any) (any, error) { return m.UserDirs(), nil },
},
{
Name: "xdg_default",
Description: "The default application for a MIME type (text/html) or a URL scheme (https, mailto): what " +
"xdg-open uses, what GTK programs use, which mimeapps list decided it, the application's desktop " +
"file and whether its program is installed. With set (a desktop file id such as firefox.desktop), " +
"make it the default through xdg-mime: it is written to the person's own list, which the mesh " +
"never writes and which outranks the mesh's defaults.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"type": map[string]any{"type": "string", "description": "a MIME type (application/pdf) or a URL scheme (https, mailto)"},
"set": map[string]any{"type": "string", "description": "make this installed desktop file id the default (an action)"},
},
"required": []string{"type"},
},
Run: func(args map[string]any) (any, error) {
t, err := text(args, "type")
if err != nil {
return nil, err
}
mime, err := Normalise(t)
if err != nil {
return nil, err
}
set, err := text(args, "set")
if err != nil {
return nil, err
}
if set != "" {
return m.SetDefault(mime, set)
}
return m.Handle(mime), nil
},
},
{
Name: "xdg_open_handlers",
Description: "Who opens the links and files that matter (http, https, mailto, HTML, folders, text, " +
"Markdown, PDF, images, video, audio, and any types given): the application xdg-open and GTK " +
"programs pick and which list decided. Also every mimeapps list in the order applications read " +
"them, every default naming an application that is not installed, and every installed handler " +
"whose program is missing.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"types": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "more MIME types or URL schemes to answer for"},
},
},
Run: func(args map[string]any) (any, error) {
var extra []string
if v, ok := args["types"]; ok && v != nil {
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("types is a list of text")
}
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("types is a list of text")
}
extra = append(extra, s)
}
}
return m.OpenHandlers(extra)
},
},
{
Name: "xdg_autostart",
Description: "Every XDG autostart entry, the account's and the system's: what it runs, Hidden, " +
"OnlyShowIn and NotShowIn, whether dex starts it at login under the window manager (and if not, " +
"why), whether the window manager's own configuration starts the same program too, and how many " +
"of the account's processes run it now.",
Input: map[string]any{"type": "object", "properties": map[string]any{"environment": envArg}},
Run: func(args map[string]any) (any, error) {
e, err := env(args)
if err != nil {
return nil, err
}
return map[string]any{"environment": e, "entries": m.Autostarts(e)}, nil
},
},
{
Name: "xdg_check",
Description: "Whether the account's XDG conventions are sound: user-dirs.dirs is the mesh's, every folder " +
"it names exists and xdg-user-dirs-update cannot rewrite it; the mesh's default applications are " +
"in place, every mimeapps list parses with nothing written twice, no default names an application " +
"that is not installed, no handler's program is missing; no program is started twice at login and " +
"every program dex starts is installed.",
Input: map[string]any{"type": "object", "properties": map[string]any{"environment": envArg}},
Run: func(args map[string]any) (any, error) {
e, err := env(args)
if err != nil {
return nil, err
}
return m.Check(e), nil
},
},
}
}
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
)
// xdg's shape: it claims no seat (the conventions are not a role anything calls), needs no
// display, owns the account's folders file whole and the machine's lowest list of default
// applications, and holds the person's own list of defaults and every autostart entry as found.
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []any `json:"claims"`
Tools []string `json:"tools"`
Shell []any `json:"shell"`
Contributions []any `json:"contributions"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, string(raw)
}
func TestItClaimsNoSeatNeedsNoDisplayAndContributesNothing(t *testing.T) {
m, _ := readManifest(t)
if m.Module != "xdg" || m.Claims != nil || m.Requires != nil || m.Shell != nil || m.Contributions != nil {
t.Fatalf("%+v", m)
}
if !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%v", m.Capabilities)
}
}
func TestItOwnsTheFilesItsSourcesHold(t *testing.T) {
m, _ := readManifest(t)
want := map[string][3]string{
"user-dirs": {"${machine:account-home}/.config/user-dirs.dirs", "0600", "user-dirs.dirs"},
"user-dirs-locale": {"${machine:account-home}/.config/user-dirs.locale", "0644", "user-dirs.locale"},
"user-dirs-update": {"${machine:account-home}/.config/user-dirs.conf", "0644", "user-dirs.conf"},
"default-applications": {MeshList, "0644", "mimeapps.list"},
}
var pkgs []string
files := 0
for _, r := range m.Resources {
id, _ := r["id"].(string)
if r["type"] == "package" {
pkgs = append(pkgs, r["package"].(string))
continue
}
w, ok := want[id]
if !ok || r["type"] != "file" {
t.Errorf("unexpected resource %v", r)
continue
}
files++
src, _ := os.ReadFile(filepath.Join("..", "..", "config", w[2]))
if r["path"] != w[0] || r["mode"] != w[1] || r["content"] != string(src) || r["into"] != nil {
t.Errorf("%s is not config/%s, whole, at %s mode %s: %v", id, w[2], w[0], w[1], r["path"])
}
home := strings.HasPrefix(w[0], "${machine:account-home}/")
if home != (r["owner"] == "${machine:account}") {
t.Errorf("%s: a file in the home is the account's, a system file root's: owner %v", id, r["owner"])
}
}
if files != len(want) {
t.Errorf("%d files, want %d", files, len(want))
}
if !reflect.DeepEqual(pkgs, []string{"xdg-user-dirs", "xdg-utils"}) {
t.Errorf("packages %v", pkgs)
}
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "mimeapps.list") && strings.Contains(p, "account-home") {
t.Errorf("the person's mimeapps.list is held as found, never written: %v", r)
}
if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "xdg-desktop-portal") {
t.Errorf("autostart entries are their applications' modules', the portal is adwaita's: %v", r)
}
}
}
func TestTheFoldersFileIsXdgUserDirsOwnFormatWithThreeSettings(t *testing.T) {
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.dirs"))
text := string(raw)
settings := regexp.MustCompile(`\$\{setting:([a-z0-9-]+)\}`).FindAllStringSubmatch(text, -1)
var names []string
for _, s := range settings {
names = append(names, s[1])
}
sort.Strings(names)
if !reflect.DeepEqual(names, []string{"desktop-dir", "publicshare-dir", "videos-dir"}) {
t.Fatalf("the folders that differ between the workstations are the settings: %v", names)
}
lines := 0
for _, l := range strings.Split(strings.TrimSpace(text), "\n") {
if strings.HasPrefix(l, "#") {
continue
}
lines++
if !userDirLine.MatchString(l) {
t.Errorf("not xdg-user-dirs' format: %q", l)
}
}
// The eight folders GLib knows, and xdg-user-dirs' PROJECTS.
if lines != 9 {
t.Errorf("%d folders", lines)
}
if !strings.Contains(firstLines(text, 3), meshMark) {
t.Error("the header xdg_check recognises the file by")
}
conf, _ := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.conf"))
if !regexp.MustCompile(`(?m)^enabled=False$`).Match(conf) {
t.Error("xdg-user-dirs-update must be disabled, or it rewrites the mesh's file at login")
}
}
func TestTheMeshsDefaultsNameEachTypeOnceInOneGroupEach(t *testing.T) {
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "mimeapps.list"))
k := ParseKeyFile(string(raw))
if len(k.DupGroups)+len(k.DupKeys)+len(k.Unparsable) != 0 {
t.Fatalf("%+v", k)
}
if !reflect.DeepEqual(k.Order, []string{"Default Applications", "Added Associations"}) {
t.Fatalf("%v", k.Order)
}
for typ, v := range k.Groups["Default Applications"] {
if _, err := Normalise(typ); err != nil {
t.Errorf("%s: %v", typ, err)
}
for _, id := range List(v) {
if !desktopID.MatchString(id) || regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-`).MatchString(id) {
t.Errorf("%s=%s: an id one machine generated is not a default for every machine", typ, id)
}
if id == "code-oss.desktop" {
t.Errorf("%s: code-oss is installed on neither workstation", typ)
}
}
}
if k.Groups["Default Applications"]["x-scheme-handler/https"] != "firefox.desktop;" {
t.Error("the browser is the operator's")
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools(&Machine{}) {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "xdg_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s", tool.Name)
}
}
if len(served) != len(m.Tools) {
t.Fatalf("%v %v", served, m.Tools)
}
for _, n := range m.Tools {
if !served[n] {
t.Errorf("module.json lists %s, which the bundle does not serve", n)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" || bundle["from"] != "cmd/xdg-tools" || bundle["binary"] != "xdg-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "zurag", ".hal/", "api_key"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
+301
View File
@@ -0,0 +1,301 @@
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
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
)
// meshMark is in the first lines of every file this module writes.
const meshMark = "(module xdg"
// UserDir is one line of user-dirs.dirs.
type UserDir struct {
Name string `json:"name"`
Written string `json:"written"`
Path string `json:"path"`
// Home is a folder pointed at the home itself: xdg-user-dirs' way of saying "not wanted".
Home bool `json:"is_home"`
Exists bool `json:"exists"`
Kind string `json:"kind"` // directory, file, absent
// Default is the folder xdg-user-dirs would have made, from its system defaults.
Default string `json:"xdg_user_dirs_default,omitempty"`
}
var userDirLine = regexp.MustCompile(`^XDG_([A-Z0-9_]+)_DIR="(.*)"\s*$`)
// ParseUserDirs reads user-dirs.dirs as xdg-user-dirs writes it: XDG_xxx_DIR="$HOME/yyy" or "/yyy".
// The order of the file is kept.
func ParseUserDirs(text string) [][2]string {
var out [][2]string
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if m := userDirLine.FindStringSubmatch(strings.TrimSpace(sc.Text())); m != nil {
out = append(out, [2]string{m[1], strings.ReplaceAll(m[2], `\`, "")})
}
}
return out
}
// resolve turns a written value into a path, as xdg-user-dir does.
func (m *Machine) resolve(written string) string {
switch {
case written == "$HOME" || written == "$HOME/":
return m.Home
case strings.HasPrefix(written, "$HOME/"):
return filepath.Clean(filepath.Join(m.Home, strings.TrimPrefix(written, "$HOME/")))
}
return filepath.Clean(written)
}
func (m *Machine) kind(path string) string {
info, err := os.Stat(m.p(path))
switch {
case err != nil:
return "absent"
case info.IsDir():
return "directory"
}
return "file"
}
// UserDirsAnswer is xdg_user_dirs' answer.
type UserDirsAnswer struct {
File string `json:"file"`
Meshs bool `json:"written_by_the_mesh"`
Dirs []UserDir `json:"dirs"`
Missing []string `json:"defaults_without_a_line,omitempty"`
Update struct {
Enabled bool `json:"enabled"`
DecidedBy string `json:"decided_by"`
Note string `json:"note"`
} `json:"xdg_user_dirs_update"`
Locale string `json:"locale,omitempty"`
}
// userDirsDefaults reads the names xdg-user-dirs would create, from its system defaults.
func (m *Machine) userDirsDefaults() map[string]string {
out := map[string]string{}
text, _ := m.read("/etc/xdg/user-dirs.defaults")
for _, l := range strings.Split(text, "\n") {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
if k, v, ok := strings.Cut(l, "="); ok {
out[k] = v
}
}
return out
}
// updateEnabled is xdg-user-dirs-update's own reading of `enabled`: the account's user-dirs.conf
// over the system's, the last line saying it winning.
func (m *Machine) updateEnabled() (bool, string) {
enabled, by := true, "xdg-user-dirs' built-in default"
for _, f := range []string{"/etc/xdg/user-dirs.conf", filepath.Join(m.configHome(), "user-dirs.conf")} {
text, ok := m.read(f)
if !ok {
continue
}
for _, l := range strings.Split(text, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(l), "=")
if ok && strings.TrimSpace(k) == "enabled" {
enabled, by = strings.EqualFold(strings.TrimSpace(v), "true"), m.tilde(f)
}
}
}
return enabled, by
}
// UserDirs is xdg_user_dirs.
func (m *Machine) UserDirs() UserDirsAnswer {
var a UserDirsAnswer
file := filepath.Join(m.configHome(), "user-dirs.dirs")
a.File = m.tilde(file)
text, _ := m.read(file)
a.Meshs = strings.Contains(firstLines(text, 3), meshMark)
defaults := m.userDirsDefaults()
seen := map[string]bool{}
for _, kv := range ParseUserDirs(text) {
path := m.resolve(kv[1])
d := UserDir{Name: kv[0], Written: kv[1], Path: m.tilde(path), Home: path == m.Home, Kind: m.kind(path)}
d.Exists = d.Kind == "directory"
if def, ok := defaults[kv[0]]; ok {
d.Default = "~/" + def
}
seen[kv[0]] = true
a.Dirs = append(a.Dirs, d)
}
for _, k := range sortedKeys(defaults) {
if !seen[k] {
a.Missing = append(a.Missing, k)
}
}
a.Update.Enabled, a.Update.DecidedBy = m.updateEnabled()
if a.Update.Enabled {
a.Update.Note = "enabled: at the next login it reassigns a folder it cannot find to the home, adds a line for each default above, and rewrites the file"
} else {
a.Update.Note = "disabled: it leaves the file as it is"
}
if l, ok := m.read(filepath.Join(m.configHome(), "user-dirs.locale")); ok {
a.Locale = strings.TrimSpace(l)
}
return a
}
func firstLines(text string, n int) string {
lines := strings.SplitN(text, "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+30
View File
@@ -0,0 +1,30 @@
# The machine's default applications (module xdg, novox/hq ADR 0182), written by the mesh and
# replaced at every push.
#
# This is the lowest list an application consults. The account's own ~/.config/mimeapps.list comes
# first: every program that offers "always open with" writes there, and so does xdg_default. That
# file is the person's and is never written by the mesh, so a choice made there always wins; this
# list answers only what the person has not chosen. Adopted from the workstations' own lists of
# 2026-10-05, without the entries naming an application neither workstation has installed (see the
# module's README).
[Default Applications]
application/pdf=firefox.desktop;
application/x-extension-htm=firefox.desktop;
application/x-extension-shtml=firefox.desktop;
application/x-extension-xht=firefox.desktop;
application/x-extension-xhtml=firefox.desktop;
application/xhtml+xml=firefox.desktop;
text/html=firefox.desktop;
x-scheme-handler/chrome=firefox.desktop;
x-scheme-handler/ftp=firefox.desktop;
x-scheme-handler/http=firefox.desktop;
x-scheme-handler/https=firefox.desktop;
text/markdown=org.kde.ghostwriter.desktop;
inode/directory=thunar.desktop;
x-scheme-handler/abc=plexamp.desktop;
x-scheme-handler/jetbrains=jetbrains-toolbox.desktop;
x-scheme-handler/slack=slack.desktop;
x-scheme-handler/tg=org.telegram.desktop.desktop;
[Added Associations]
video/mp4=mpv.desktop;mplayer.desktop;vlc.desktop;
+9
View File
@@ -0,0 +1,9 @@
# xdg-user-dirs-update for this account (module xdg, novox/hq ADR 0182), written by the mesh and
# replaced at every push.
#
# The update runs at every login (its XDG autostart entry, which the session's dex starts). Enabled,
# it reassigns a folder it cannot find to the home and adds a line for every folder its defaults
# know, rewriting user-dirs.dirs, which is the mesh's. Disabled, it does nothing, even with --force.
# The folders are this module's to say.
enabled=False
filename_encoding=UTF-8
+17
View File
@@ -0,0 +1,17 @@
# The account's folders (module xdg, novox/hq ADR 0182), written by the mesh and replaced at every
# push. Adopted from the workstations' own files of 2026-10-05. A folder that differs between the
# machines is a setting of this module; every other one is the same everywhere. PROJECTS names the
# folder the operator's repositories are in (it pointed at the home, or was absent).
#
# xdg-user-dirs-update never rewrites this file: user-dirs.conf beside it says enabled=False. Each
# line is xdg-user-dirs' only format, XDG_xxx_DIR="$HOME/yyy" or XDG_xxx_DIR="/yyy". "$HOME/" is
# the home itself: the folder is not wanted. xdg_user_dirs says what each one resolves to.
XDG_DESKTOP_DIR="${setting:desktop-dir}"
XDG_DOWNLOAD_DIR="$HOME/Downloads"
XDG_TEMPLATES_DIR="$HOME/Templates"
XDG_PUBLICSHARE_DIR="${setting:publicshare-dir}"
XDG_DOCUMENTS_DIR="$HOME/Documents"
XDG_MUSIC_DIR="$HOME/Music"
XDG_PICTURES_DIR="$HOME/Pictures"
XDG_VIDEOS_DIR="${setting:videos-dir}"
XDG_PROJECTS_DIR="$HOME/projects"
+1
View File
@@ -0,0 +1 @@
en_US
+5
View File
@@ -0,0 +1,5 @@
module xdg
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+72
View File
@@ -0,0 +1,72 @@
{
"module": "xdg",
"version": "1",
"capabilities": [
"package-manager"
],
"tools": [
"xdg_user_dirs",
"xdg_default",
"xdg_open_handlers",
"xdg_autostart",
"xdg_check"
],
"resources": [
{
"id": "package-xdg-user-dirs",
"type": "package",
"package": "xdg-user-dirs"
},
{
"id": "package-xdg-utils",
"type": "package",
"package": "xdg-utils"
},
{
"id": "user-dirs",
"type": "file",
"path": "${machine:account-home}/.config/user-dirs.dirs",
"owner": "${machine:account}",
"mode": "0600",
"content": "# The account's folders (module xdg, novox/hq ADR 0182), written by the mesh and replaced at every\n# push. Adopted from the workstations' own files of 2026-10-05. A folder that differs between the\n# machines is a setting of this module; every other one is the same everywhere. PROJECTS names the\n# folder the operator's repositories are in (it pointed at the home, or was absent).\n#\n# xdg-user-dirs-update never rewrites this file: user-dirs.conf beside it says enabled=False. Each\n# line is xdg-user-dirs' only format, XDG_xxx_DIR=\"$HOME/yyy\" or XDG_xxx_DIR=\"/yyy\". \"$HOME/\" is\n# the home itself: the folder is not wanted. xdg_user_dirs says what each one resolves to.\nXDG_DESKTOP_DIR=\"${setting:desktop-dir}\"\nXDG_DOWNLOAD_DIR=\"$HOME/Downloads\"\nXDG_TEMPLATES_DIR=\"$HOME/Templates\"\nXDG_PUBLICSHARE_DIR=\"${setting:publicshare-dir}\"\nXDG_DOCUMENTS_DIR=\"$HOME/Documents\"\nXDG_MUSIC_DIR=\"$HOME/Music\"\nXDG_PICTURES_DIR=\"$HOME/Pictures\"\nXDG_VIDEOS_DIR=\"${setting:videos-dir}\"\nXDG_PROJECTS_DIR=\"$HOME/projects\"\n"
},
{
"id": "user-dirs-locale",
"type": "file",
"path": "${machine:account-home}/.config/user-dirs.locale",
"owner": "${machine:account}",
"mode": "0644",
"content": "en_US\n"
},
{
"id": "user-dirs-update",
"type": "file",
"path": "${machine:account-home}/.config/user-dirs.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# xdg-user-dirs-update for this account (module xdg, novox/hq ADR 0182), written by the mesh and\n# replaced at every push.\n#\n# The update runs at every login (its XDG autostart entry, which the session's dex starts). Enabled,\n# it reassigns a folder it cannot find to the home and adds a line for every folder its defaults\n# know, rewriting user-dirs.dirs, which is the mesh's. Disabled, it does nothing, even with --force.\n# The folders are this module's to say.\nenabled=False\nfilename_encoding=UTF-8\n"
},
{
"id": "default-applications",
"type": "file",
"path": "/etc/xdg/mimeapps.list",
"mode": "0644",
"content": "# The machine's default applications (module xdg, novox/hq ADR 0182), written by the mesh and\n# replaced at every push.\n#\n# This is the lowest list an application consults. The account's own ~/.config/mimeapps.list comes\n# first: every program that offers \"always open with\" writes there, and so does xdg_default. That\n# file is the person's and is never written by the mesh, so a choice made there always wins; this\n# list answers only what the person has not chosen. Adopted from the workstations' own lists of\n# 2026-10-05, without the entries naming an application neither workstation has installed (see the\n# module's README).\n[Default Applications]\napplication/pdf=firefox.desktop;\napplication/x-extension-htm=firefox.desktop;\napplication/x-extension-shtml=firefox.desktop;\napplication/x-extension-xht=firefox.desktop;\napplication/x-extension-xhtml=firefox.desktop;\napplication/xhtml+xml=firefox.desktop;\ntext/html=firefox.desktop;\nx-scheme-handler/chrome=firefox.desktop;\nx-scheme-handler/ftp=firefox.desktop;\nx-scheme-handler/http=firefox.desktop;\nx-scheme-handler/https=firefox.desktop;\ntext/markdown=org.kde.ghostwriter.desktop;\ninode/directory=thunar.desktop;\nx-scheme-handler/abc=plexamp.desktop;\nx-scheme-handler/jetbrains=jetbrains-toolbox.desktop;\nx-scheme-handler/slack=slack.desktop;\nx-scheme-handler/tg=org.telegram.desktop.desktop;\n\n[Added Associations]\nvideo/mp4=mpv.desktop;mplayer.desktop;vlc.desktop;\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xdg-tools",
"binary": "xdg-tools",
"loads": [
"xdg-tools"
]
}
]
}
}