From 785d32407b15a28f01c1164203b0f76f9b27bc95 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 11:50:25 +0200 Subject: [PATCH] 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. --- modules/xdg/README.md | 182 +++++++++ modules/xdg/cmd/xdg-tools/autostart.go | 234 +++++++++++ modules/xdg/cmd/xdg-tools/check.go | 126 ++++++ modules/xdg/cmd/xdg-tools/desktop.go | 234 +++++++++++ modules/xdg/cmd/xdg-tools/machine.go | 209 ++++++++++ modules/xdg/cmd/xdg-tools/machine_test.go | 429 +++++++++++++++++++++ modules/xdg/cmd/xdg-tools/main.go | 150 +++++++ modules/xdg/cmd/xdg-tools/manifest_test.go | 196 ++++++++++ modules/xdg/cmd/xdg-tools/mime.go | 301 +++++++++++++++ modules/xdg/cmd/xdg-tools/userdirs.go | 155 ++++++++ modules/xdg/config/mimeapps.list | 30 ++ modules/xdg/config/user-dirs.conf | 9 + modules/xdg/config/user-dirs.dirs | 17 + modules/xdg/config/user-dirs.locale | 1 + modules/xdg/go.mod | 5 + modules/xdg/go.sum | 2 + modules/xdg/module.json | 72 ++++ 17 files changed, 2352 insertions(+) create mode 100644 modules/xdg/README.md create mode 100644 modules/xdg/cmd/xdg-tools/autostart.go create mode 100644 modules/xdg/cmd/xdg-tools/check.go create mode 100644 modules/xdg/cmd/xdg-tools/desktop.go create mode 100644 modules/xdg/cmd/xdg-tools/machine.go create mode 100644 modules/xdg/cmd/xdg-tools/machine_test.go create mode 100644 modules/xdg/cmd/xdg-tools/main.go create mode 100644 modules/xdg/cmd/xdg-tools/manifest_test.go create mode 100644 modules/xdg/cmd/xdg-tools/mime.go create mode 100644 modules/xdg/cmd/xdg-tools/userdirs.go create mode 100644 modules/xdg/config/mimeapps.list create mode 100644 modules/xdg/config/user-dirs.conf create mode 100644 modules/xdg/config/user-dirs.dirs create mode 100644 modules/xdg/config/user-dirs.locale create mode 100644 modules/xdg/go.mod create mode 100644 modules/xdg/go.sum create mode 100644 modules/xdg/module.json diff --git a/modules/xdg/README.md b/modules/xdg/README.md new file mode 100644 index 0000000..e2aa38a --- /dev/null +++ b/modules/xdg/README.md @@ -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). diff --git a/modules/xdg/cmd/xdg-tools/autostart.go b/modules/xdg/cmd/xdg-tools/autostart.go new file mode 100644 index 0000000..816c4ad --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/autostart.go @@ -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 ` 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 +} diff --git a/modules/xdg/cmd/xdg-tools/check.go b/modules/xdg/cmd/xdg-tools/check.go new file mode 100644 index 0000000..35c95a4 --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/check.go @@ -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, "; ") +} diff --git a/modules/xdg/cmd/xdg-tools/desktop.go b/modules/xdg/cmd/xdg-tools/desktop.go new file mode 100644 index 0000000..f15b7d5 --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/desktop.go @@ -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 +} diff --git a/modules/xdg/cmd/xdg-tools/machine.go b/modules/xdg/cmd/xdg-tools/machine.go new file mode 100644 index 0000000..53bde45 --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/machine.go @@ -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 +} diff --git a/modules/xdg/cmd/xdg-tools/machine_test.go b/modules/xdg/cmd/xdg-tools/machine_test.go new file mode 100644 index 0000000..cb3570b --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/machine_test.go @@ -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 == "": + 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": "", + home + "/projects": "", + }) + 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) + } +} diff --git a/modules/xdg/cmd/xdg-tools/main.go b/modules/xdg/cmd/xdg-tools/main.go new file mode 100644 index 0000000..e83eb24 --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/main.go @@ -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 + }, + }, + } +} diff --git a/modules/xdg/cmd/xdg-tools/manifest_test.go b/modules/xdg/cmd/xdg-tools/manifest_test.go new file mode 100644 index 0000000..16ca374 --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/manifest_test.go @@ -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) + } + } +} diff --git a/modules/xdg/cmd/xdg-tools/mime.go b/modules/xdg/cmd/xdg-tools/mime.go new file mode 100644 index 0000000..f586efd --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/mime.go @@ -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 +} diff --git a/modules/xdg/cmd/xdg-tools/userdirs.go b/modules/xdg/cmd/xdg-tools/userdirs.go new file mode 100644 index 0000000..6350d4c --- /dev/null +++ b/modules/xdg/cmd/xdg-tools/userdirs.go @@ -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") +} diff --git a/modules/xdg/config/mimeapps.list b/modules/xdg/config/mimeapps.list new file mode 100644 index 0000000..b9c21db --- /dev/null +++ b/modules/xdg/config/mimeapps.list @@ -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; diff --git a/modules/xdg/config/user-dirs.conf b/modules/xdg/config/user-dirs.conf new file mode 100644 index 0000000..f26143a --- /dev/null +++ b/modules/xdg/config/user-dirs.conf @@ -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 diff --git a/modules/xdg/config/user-dirs.dirs b/modules/xdg/config/user-dirs.dirs new file mode 100644 index 0000000..a05c6cd --- /dev/null +++ b/modules/xdg/config/user-dirs.dirs @@ -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" diff --git a/modules/xdg/config/user-dirs.locale b/modules/xdg/config/user-dirs.locale new file mode 100644 index 0000000..7741b83 --- /dev/null +++ b/modules/xdg/config/user-dirs.locale @@ -0,0 +1 @@ +en_US diff --git a/modules/xdg/go.mod b/modules/xdg/go.mod new file mode 100644 index 0000000..6f4abbd --- /dev/null +++ b/modules/xdg/go.mod @@ -0,0 +1,5 @@ +module xdg + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/xdg/go.sum b/modules/xdg/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/xdg/go.sum @@ -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= diff --git a/modules/xdg/module.json b/modules/xdg/module.json new file mode 100644 index 0000000..30788ef --- /dev/null +++ b/modules/xdg/module.json @@ -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" + ] + } + ] + } +}