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" + ] + } + ] + } +}