Merge pull request 'xdg: the account's folders and the machine's default applications' (#50) from feat/xdg-module into main
This commit was merged in pull request #50.
This commit is contained in:
@@ -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).
|
||||
@@ -0,0 +1,234 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Autostart is one XDG autostart entry, and what the session does with it.
|
||||
type Autostart struct {
|
||||
Entry
|
||||
Scope string `json:"scope"` // user or system
|
||||
// Overrides names the system entry of the same name this user entry hides; OverriddenBy the
|
||||
// user entry hiding this system one.
|
||||
Overrides string `json:"overrides,omitempty"`
|
||||
OverriddenBy string `json:"overridden_by,omitempty"`
|
||||
DexStarts bool `json:"dex_starts"`
|
||||
Why string `json:"why"`
|
||||
AlsoStarted string `json:"also_started_by,omitempty"`
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
// dexDecides says whether `dex --autostart --environment <env>` starts an entry, by dex's own
|
||||
// rules (dex 0.10): an entry of the same name in an earlier directory wins; Hidden=true, an
|
||||
// OnlyShowIn without the environment, a NotShowIn with it, a TryExec that is not there, or a type
|
||||
// other than Application keep it from starting. X-GNOME-Autostart-enabled and AutostartCondition
|
||||
// are GNOME's, and dex does not read them.
|
||||
func (m *Machine) dexDecides(a *Autostart, env string) {
|
||||
switch {
|
||||
case a.OverriddenBy != "":
|
||||
a.Why = "hidden by the user entry " + a.OverriddenBy
|
||||
case a.Broken != "":
|
||||
a.Why = a.Broken
|
||||
case a.Type != "" && a.Type != "Application":
|
||||
a.Why = "type " + a.Type + ", not Application"
|
||||
case a.Hidden:
|
||||
a.Why = "Hidden=true"
|
||||
case len(a.OnlyShow) > 0 && !contains(a.OnlyShow, env):
|
||||
a.Why = "OnlyShowIn " + strings.Join(a.OnlyShow, ";") + " does not name " + env
|
||||
case contains(a.NotShow, env):
|
||||
a.Why = "NotShowIn names " + env
|
||||
case a.TryExec != "" && !m.tryExecFound(a.TryExec):
|
||||
a.Why = "TryExec " + a.TryExec + " is not installed"
|
||||
default:
|
||||
a.DexStarts = true
|
||||
a.Why = "started at every login"
|
||||
if !a.Found {
|
||||
a.Why = "started at every login, and fails: " + a.Program + " is not installed"
|
||||
}
|
||||
if strings.EqualFold(a.GnomeEnabled, "false") {
|
||||
a.Why += "; X-GNOME-Autostart-enabled=false does not stop dex (use Hidden=true)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Machine) tryExecFound(p string) bool {
|
||||
_, ok := m.executable(p)
|
||||
return ok
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autostartDirs are the account's directory, then the system's, as dex reads them.
|
||||
func (m *Machine) autostartDirs() []struct{ dir, scope string } {
|
||||
out := []struct{ dir, scope string }{{filepath.Join(m.configHome(), "autostart"), "user"}}
|
||||
for _, d := range m.configDirs() {
|
||||
out = append(out, struct{ dir, scope string }{filepath.Join(d, "autostart"), "system"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostarts lists every entry, user and system, with dex's decision for env.
|
||||
func (m *Machine) Autostarts(env string) []Autostart {
|
||||
var out []Autostart
|
||||
first := map[string]int{}
|
||||
procs := m.processes()
|
||||
starts := m.windowManagerStarts()
|
||||
for _, d := range m.autostartDirs() {
|
||||
entries, _ := os.ReadDir(m.p(d.dir))
|
||||
for _, de := range entries {
|
||||
if !strings.HasSuffix(de.Name(), ".desktop") {
|
||||
continue
|
||||
}
|
||||
a := Autostart{Entry: m.readEntry(filepath.Join(d.dir, de.Name())), Scope: d.scope}
|
||||
if i, seen := first[de.Name()]; seen {
|
||||
a.OverriddenBy = out[i].File
|
||||
out[i].Overrides = a.File
|
||||
} else {
|
||||
first[de.Name()] = len(out)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
a := &out[i]
|
||||
m.dexDecides(a, env)
|
||||
a.Running = procs.count(a.Program)
|
||||
if by, ok := starts[filepath.Base(a.Program)]; ok && a.Program != "" {
|
||||
a.AlsoStarted = by
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// procs are the account's processes: each one's first two words, base names only, and its name.
|
||||
type procs [][3]string
|
||||
|
||||
// processes reads the account's own processes from /proc; nothing but the command line and the
|
||||
// process's name is read.
|
||||
func (m *Machine) processes() procs {
|
||||
var out procs
|
||||
entries, _ := os.ReadDir(m.p("/proc"))
|
||||
self := strconv.Itoa(os.Getpid())
|
||||
for _, e := range entries {
|
||||
if _, err := strconv.Atoi(e.Name()); err != nil || e.Name() == self {
|
||||
continue
|
||||
}
|
||||
dir := m.p(filepath.Join("/proc", e.Name()))
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "cmdline"))
|
||||
if err != nil || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
words := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
|
||||
if len(words) == 1 {
|
||||
// Chromium and Electron rewrite their command line as one string, words joined by spaces.
|
||||
words = strings.Fields(words[0])
|
||||
}
|
||||
if len(words) == 0 {
|
||||
continue
|
||||
}
|
||||
var p [3]string
|
||||
if comm, err := os.ReadFile(filepath.Join(dir, "comm")); err == nil {
|
||||
p[2] = strings.TrimSpace(string(comm))
|
||||
}
|
||||
p[0] = filepath.Base(words[0])
|
||||
if len(words) > 1 {
|
||||
p[1] = filepath.Base(words[1])
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// count is how many processes run a program: as their command, or as the script an interpreter
|
||||
// runs (a Python applet is "python3 /usr/bin/applet").
|
||||
func (ps procs) count(program string) int {
|
||||
if program == "" {
|
||||
return 0
|
||||
}
|
||||
name := filepath.Base(program)
|
||||
n := 0
|
||||
for _, p := range ps {
|
||||
// The kernel keeps a process's name to 15 bytes.
|
||||
if p[0] == name || p[1] == name && isInterpreter(p[0]) || p[2] != "" && p[2] == truncate(name, 15) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func isInterpreter(cmd string) bool {
|
||||
for _, i := range []string{"python", "perl", "sh", "bash", "node", "ruby"} {
|
||||
if strings.HasPrefix(cmd, i) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// windowManagerStarts are the programs the window manager's configuration starts itself, by base
|
||||
// name, with the line that does it. Read only: the configuration is the window manager's module's.
|
||||
func (m *Machine) windowManagerStarts() map[string]string {
|
||||
out := map[string]string{}
|
||||
files := []string{filepath.Join(m.configHome(), "i3", "config")}
|
||||
more, _ := filepath.Glob(m.p(filepath.Join(m.configHome(), "i3", "config.d", "*.conf")))
|
||||
sort.Strings(more)
|
||||
for _, f := range more {
|
||||
files = append(files, strings.TrimPrefix(f, m.Root))
|
||||
}
|
||||
for _, f := range files {
|
||||
h, err := os.Open(m.p(f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sc := bufio.NewScanner(h)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
n++
|
||||
words := strings.Fields(sc.Text())
|
||||
if len(words) < 2 || words[0] != "exec" && words[0] != "exec_always" {
|
||||
continue
|
||||
}
|
||||
rest := words[1:]
|
||||
for len(rest) > 0 && strings.HasPrefix(rest[0], "--") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
continue
|
||||
}
|
||||
prog := filepath.Base(Program(strings.Join(rest, " ")))
|
||||
if prog == "dex" {
|
||||
continue
|
||||
}
|
||||
out[prog] = m.tilde(f) + ":" + strconv.Itoa(n)
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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, "; ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const home = "/home/op"
|
||||
|
||||
// fake is a machine with these files under a temporary root; a value starting with "->" is a
|
||||
// symbolic link to the rest, and one starting with "#!" is an executable.
|
||||
func fake(t *testing.T, files map[string]string) *Machine {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for p, c := range files {
|
||||
full := filepath.Join(root, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(c, "->"):
|
||||
if err := os.Symlink(strings.TrimPrefix(c, "->"), full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case c == "<dir>":
|
||||
if err := os.MkdirAll(full, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
mode := os.FileMode(0o644)
|
||||
if strings.HasPrefix(c, "#!") {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(c), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Machine{Root: root, Home: home, UID: os.Getuid(), Desktop: "i3", Timeout: time.Second,
|
||||
Run: func(context.Context, []string, string, ...string) (string, error) { return "", nil }}
|
||||
}
|
||||
|
||||
func app(name, exec, mime string) string {
|
||||
s := "[Desktop Entry]\nType=Application\nName=" + name + "\nExec=" + exec + "\n"
|
||||
if mime != "" {
|
||||
s += "MimeType=" + mime + "\n"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// The desktop's folders as found on 2026-10-05, in the module's file with that machine's settings.
|
||||
func desktopUserDirs(t *testing.T) string {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.dirs"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := strings.NewReplacer("${setting:desktop-dir}", "$HOME/", "${setting:publicshare-dir}", "$HOME/", "${setting:videos-dir}", "$HOME/")
|
||||
return r.Replace(string(raw))
|
||||
}
|
||||
|
||||
func TestTheFoldersAreReadAsXdgUserDirResolvesThem(t *testing.T) {
|
||||
conf, _ := os.ReadFile(filepath.Join("..", "..", "config", "user-dirs.conf"))
|
||||
m := fake(t, map[string]string{
|
||||
home + "/.config/user-dirs.dirs": desktopUserDirs(t),
|
||||
home + "/.config/user-dirs.conf": string(conf),
|
||||
home + "/.config/user-dirs.locale": "en_US\n",
|
||||
"/etc/xdg/user-dirs.conf": "enabled=True\n",
|
||||
"/etc/xdg/user-dirs.defaults": "# c\nDESKTOP=Desktop\nDOWNLOAD=Downloads\nPROJECTS=Projects\nNEWTHING=New\n",
|
||||
home + "/Desktop": "a file where the folder would be\n",
|
||||
home + "/Downloads": "<dir>",
|
||||
home + "/projects": "<dir>",
|
||||
})
|
||||
a := m.UserDirs()
|
||||
if !a.Meshs || a.Locale != "en_US" || a.Update.Enabled || a.Update.DecidedBy != "~/.config/user-dirs.conf" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
got := map[string]UserDir{}
|
||||
for _, d := range a.Dirs {
|
||||
got[d.Name] = d
|
||||
}
|
||||
if len(a.Dirs) != 9 {
|
||||
t.Fatalf("%d folders: %+v", len(a.Dirs), a.Dirs)
|
||||
}
|
||||
if d := got["DESKTOP"]; !d.Home || d.Path != "~" || d.Default != "~/Desktop" {
|
||||
t.Errorf("DESKTOP: %+v", d)
|
||||
}
|
||||
if d := got["DOWNLOAD"]; d.Home || !d.Exists || d.Path != "~/Downloads" {
|
||||
t.Errorf("DOWNLOAD: %+v", d)
|
||||
}
|
||||
if d := got["PROJECTS"]; d.Path != "~/projects" || !d.Exists || d.Default != "~/Projects" {
|
||||
t.Errorf("PROJECTS: %+v", d)
|
||||
}
|
||||
if d := got["MUSIC"]; d.Exists || d.Kind != "absent" {
|
||||
t.Errorf("MUSIC: %+v", d)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Missing, []string{"NEWTHING"}) {
|
||||
t.Errorf("defaults without a line: %v", a.Missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpdateLeftEnabledIsSaid(t *testing.T) {
|
||||
m := fake(t, map[string]string{"/etc/xdg/user-dirs.conf": "# c\nenabled=True\nfilename_encoding=UTF-8\n"})
|
||||
a := m.UserDirs()
|
||||
if !a.Update.Enabled || a.Update.DecidedBy != "/etc/xdg/user-dirs.conf" || !strings.Contains(a.Update.Note, "rewrites the file") {
|
||||
t.Fatalf("%+v", a.Update)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATypeIsAMimeTypeOrAScheme(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https": "x-scheme-handler/https", "mailto:": "x-scheme-handler/mailto", "http://": "x-scheme-handler/http",
|
||||
"text/html": "text/html", "x-scheme-handler/tg": "x-scheme-handler/tg", "application/xhtml+xml": "application/xhtml+xml",
|
||||
} {
|
||||
if got, err := Normalise(in); err != nil || got != want {
|
||||
t.Errorf("%s: %s %v", in, got, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "text/html; rm -rf", "../x", "Text Html"} {
|
||||
if _, err := Normalise(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProgramIsFoundBehindLaunchers(t *testing.T) {
|
||||
// Exec lines found on the workstations.
|
||||
for exec, want := range map[string]string{
|
||||
`env APPIMAGELAUNCHER_DISABLE=1 DESKTOPINTEGRATION=0 /usr/bin/Plexamp.AppImage --disable-seccomp-filter-sandbox`: "/usr/bin/Plexamp.AppImage",
|
||||
`sh -c "/opt/forticlient/gui/FortiClient >/dev/null 2>&1"`: "/opt/forticlient/gui/FortiClient",
|
||||
`zsh -c 'dockly'`: "dockly",
|
||||
`"/opt/claude-code/bin/claude" --handle-uri %u`: "/opt/claude-code/bin/claude",
|
||||
`env LIBGL_ALWAYS_SOFTWARE=1 Telegram -- %U`: "Telegram",
|
||||
`/usr/bin/slack --gtk-version=3 -s %U`: "/usr/bin/slack",
|
||||
`"/path with space/Fleet" %u`: "/path with space/Fleet",
|
||||
`steam steam://rungameid/427520`: "steam",
|
||||
`xterm -fa monospace -fs 11 -e glow -p %f`: "xterm",
|
||||
``: "",
|
||||
} {
|
||||
if got := Program(exec); got != want {
|
||||
t.Errorf("%s: %q, want %q", exec, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestADuplicateGroupOrKeyIsReportedNotMerged(t *testing.T) {
|
||||
k := ParseKeyFile("# >>> mesh >>>\n[Default Applications]\nx=a.desktop\n# <<<\n\n[Default Applications]\nx=b.desktop\ny=c\n[Added Associations]\nz=d;\nstray\n")
|
||||
if !reflect.DeepEqual(k.DupGroups, []string{"Default Applications"}) || !reflect.DeepEqual(k.DupKeys, []string{"Default Applications: x"}) {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
if k.Groups["Default Applications"]["x"] != "a.desktop" || k.Groups["Default Applications"]["y"] != "c" || len(k.Unparsable) != 1 {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with the mesh's list, the person's list, and some applications.
|
||||
func mimeMachine(t *testing.T, extra map[string]string) *Machine {
|
||||
mesh, err := os.ReadFile(filepath.Join("..", "..", "config", "mimeapps.list"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := map[string]string{
|
||||
MeshList: string(mesh),
|
||||
home + "/.config/mimeapps.list": "[Default Applications]\ntext/plain=code-oss.desktop;\ninode/directory=xplr.desktop\nx-scheme-handler/https=gone.desktop;chromium.desktop;\n",
|
||||
"/usr/share/applications/firefox.desktop": app("Firefox", "/usr/lib/firefox/firefox %u", "text/html;x-scheme-handler/https;"),
|
||||
"/usr/share/applications/chromium.desktop": app("Chromium", "/usr/bin/chromium %U", "x-scheme-handler/https;"),
|
||||
"/usr/share/applications/thunar.desktop": app("Thunar", "thunar %F", "inode/directory;"),
|
||||
home + "/.local/share/applications/xplr.desktop": app("xplr", "xplr", "inode/directory;"),
|
||||
home + "/.local/share/applications/fleet.desktop": app("Fleet", `"/home/op/fleet/bin/Fleet" %u`, "text/plain;"),
|
||||
"/usr/lib/firefox/firefox": "#!/bin/sh\n",
|
||||
"/usr/bin/chromium": "#!/bin/sh\n",
|
||||
"/usr/bin/thunar": "#!/bin/sh\n",
|
||||
}
|
||||
for k, v := range extra {
|
||||
files[k] = v
|
||||
}
|
||||
return fake(t, files)
|
||||
}
|
||||
|
||||
func TestTheDefaultIsDecidedInTheSpecificationsOrder(t *testing.T) {
|
||||
m := mimeMachine(t, nil)
|
||||
// The person's list names an application that is gone, then one that is installed.
|
||||
if d := m.Decide("x-scheme-handler/https"); d.ID != "chromium.desktop" || d.File != "~/.config/mimeapps.list" ||
|
||||
!reflect.DeepEqual(d.Skipped, []string{"gone.desktop (~/.config/mimeapps.list): not installed"}) {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
// The person's choice wins over the mesh's.
|
||||
if d := m.Decide("inode/directory"); d.ID != "xplr.desktop" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
// What the person has not chosen, the mesh's list answers.
|
||||
if d := m.Decide("text/html"); d.ID != "firefox.desktop" || d.File != MeshList {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
// Nothing installed is named: no list decides.
|
||||
if d := m.Decide("text/plain"); d.ID != "" || len(d.Skipped) != 1 {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
// The desktop's own list comes first of all.
|
||||
m2 := mimeMachine(t, map[string]string{home + "/.config/i3-mimeapps.list": "[Default Applications]\ninode/directory=thunar.desktop\n"})
|
||||
if d := m2.Decide("inode/directory"); d.ID != "thunar.desktop" || d.File != "~/.config/i3-mimeapps.list" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// recorded answers xdg-mime and gio as they answered on the laptop on 2026-10-05.
|
||||
func recorded(calls *[]string) Runner {
|
||||
return func(_ context.Context, env []string, name string, args ...string) (string, error) {
|
||||
*calls = append(*calls, name+" "+strings.Join(args, " "))
|
||||
key := name + " " + strings.Join(args, " ")
|
||||
switch key {
|
||||
case "xdg-mime query default audio/mpeg":
|
||||
return "audacity.desktop\n", nil
|
||||
case "gio mime audio/mpeg":
|
||||
return "Default application for “audio/mpeg”: mpv.desktop\nRegistered applications:\n\tmpv.desktop\n\taudacity.desktop\nRecommended applications:\n\tmpv.desktop\n\taudacity.desktop\n", nil
|
||||
case "xdg-mime query default x-scheme-handler/https":
|
||||
return "chromium.desktop\n", nil
|
||||
case "gio mime x-scheme-handler/https":
|
||||
return "Default application for “x-scheme-handler/https”: chromium.desktop\nRegistered applications:\n\tchromium.desktop\n", nil
|
||||
case "xdg-mime query default x-scheme-handler/nothing-here":
|
||||
return "", nil
|
||||
case "gio mime x-scheme-handler/nothing-here":
|
||||
return "No default applications for “x-scheme-handler/nothing-here”\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoReadersThatDisagreeAreSaid(t *testing.T) {
|
||||
var calls []string
|
||||
m := mimeMachine(t, map[string]string{
|
||||
"/usr/share/applications/audacity.desktop": app("Audacity", "audacity %F", "audio/mpeg;"),
|
||||
"/usr/share/applications/mpv.desktop": app("mpv", "mpv -- %U", "audio/mpeg;video/mp4;"),
|
||||
"/usr/bin/audacity": "#!/bin/sh\n",
|
||||
})
|
||||
m.Run = recorded(&calls)
|
||||
h := m.Handle("audio/mpeg")
|
||||
if h.XdgOpen != "audacity.desktop" || h.Gio != "mpv.desktop" || !h.Disagree || h.App == nil || !h.App.Found {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if !strings.Contains(h.Decision.File, "no list names") {
|
||||
t.Fatalf("%+v", h.Decision)
|
||||
}
|
||||
none := m.Handle("x-scheme-handler/nothing-here")
|
||||
if none.XdgOpen != "" || none.Gio != "" || none.Problem != "nothing opens it" {
|
||||
t.Fatalf("%+v", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEnvironmentGivenIsTheAccountsXdgOnly(t *testing.T) {
|
||||
var env []string
|
||||
m := mimeMachine(t, nil)
|
||||
m.Run = func(_ context.Context, e []string, _ string, _ ...string) (string, error) { env = e; return "", nil }
|
||||
m.Handle("text/html")
|
||||
joined := strings.Join(env, "\n")
|
||||
for _, want := range []string{"HOME=" + home, "XDG_CONFIG_HOME=" + home + "/.config", "XDG_CURRENT_DESKTOP=i3", "LANG=C.UTF-8"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("lacks %s: %v", want, env)
|
||||
}
|
||||
}
|
||||
if len(env) != 7 {
|
||||
t.Errorf("only the seven XDG words are passed on: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingADefaultWritesThePersonsListThroughXdgMime(t *testing.T) {
|
||||
var calls []string
|
||||
m := mimeMachine(t, nil)
|
||||
m.Run = recorded(&calls)
|
||||
a, err := m.SetDefault("x-scheme-handler/https", "chromium.desktop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(calls, "xdg-mime default chromium.desktop x-scheme-handler/https") {
|
||||
t.Fatalf("%v", calls)
|
||||
}
|
||||
if a["written_to"] != "~/.config/mimeapps.list" || a["after"] != "chromium.desktop" || a["problem"] != nil {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
for _, refused := range []string{"gone.desktop", "../../etc/passwd", "firefox", "x.desktop; rm"} {
|
||||
calls = nil
|
||||
if _, err := m.SetDefault("text/html", refused); err == nil || len(calls) != 0 {
|
||||
t.Errorf("%s: %v %v", refused, err, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadDefaultsBrokenHandlersAndListsAreFound(t *testing.T) {
|
||||
m := mimeMachine(t, map[string]string{
|
||||
home + "/.local/share/applications/mimeapps.list": "->/home/op/.retired/dotfiles/mimeapps.list",
|
||||
home + "/.local/share/applications/defaults.list": "->/home/op/.retired/dotfiles/mimeapps.list",
|
||||
home + "/.local/share/applications/fleet-old.desktop": app("Fleet", "/home/op/gone/Fleet %u", "x-scheme-handler/fleet;"),
|
||||
})
|
||||
dead := m.deadDefaults()
|
||||
var ids []string
|
||||
for _, d := range dead {
|
||||
// The fake installs none of the mesh's list's applications but firefox and thunar.
|
||||
if d.File == "~/.config/mimeapps.list" {
|
||||
ids = append(ids, d.Type+"="+d.ID)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(ids, []string{"text/plain=code-oss.desktop", "x-scheme-handler/https=gone.desktop"}) {
|
||||
t.Fatalf("%v", ids)
|
||||
}
|
||||
var broken []string
|
||||
for _, e := range m.brokenHandlers() {
|
||||
broken = append(broken, e.ID)
|
||||
}
|
||||
// xplr's program is not installed in the fake, nor fleet's.
|
||||
if !reflect.DeepEqual(broken, []string{"fleet-old.desktop", "fleet.desktop", "xplr.desktop"}) {
|
||||
t.Fatalf("%v", broken)
|
||||
}
|
||||
c := m.Check("i3")
|
||||
checks := map[string]Check{}
|
||||
for _, x := range c["checks"].([]Check) {
|
||||
checks[x.Name] = x
|
||||
}
|
||||
if s := checks["the mimeapps lists are sane"]; s.OK || !strings.Contains(s.Detail, "a link to nothing") || !strings.Contains(s.Detail, "defaults.list") {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s := checks["the mesh's default applications are in place"]; !s.OK {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshBlockInThePersonsListIsWhatTheCheckRefuses(t *testing.T) {
|
||||
// Why the module does not write a block into ~/.config/mimeapps.list: GLib reads the last of two
|
||||
// groups, xdg-mime the first, and either rewrites the file around the block.
|
||||
m := mimeMachine(t, map[string]string{
|
||||
home + "/.config/mimeapps.list": "# >>> mesh >>>\n[Default Applications]\ntext/html=firefox.desktop\n# <<< mesh <<<\n[Default Applications]\ntext/html=chromium.desktop\n",
|
||||
})
|
||||
c := m.Check("i3")
|
||||
for _, x := range c["checks"].([]Check) {
|
||||
if x.Name == "the mimeapps lists are sane" && (x.OK || !strings.Contains(x.Detail, "written twice")) {
|
||||
t.Fatalf("%+v", x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func autostartMachine(t *testing.T) *Machine {
|
||||
return fake(t, map[string]string{
|
||||
home + "/.config/autostart/slack.desktop": app("Slack", "/usr/bin/slack -s %U", "") + "Hidden=false\n",
|
||||
home + "/.config/autostart/blueman.desktop": app("Blueman", "blueman-applet", "") + "Hidden=true\n",
|
||||
home + "/.config/autostart/nextcloud.desktop": app("Nextcloud", `"/usr/bin/nextcloud" --background`, "") + "X-GNOME-Autostart-enabled=false\n",
|
||||
"/etc/xdg/autostart/blueman.desktop": app("Blueman", "blueman-applet", ""),
|
||||
"/etc/xdg/autostart/picom.desktop": app("picom", "picom", "") + "TryExec=picom\n",
|
||||
"/etc/xdg/autostart/nm-applet.desktop": app("nm", "nm-applet", "") + "NotShowIn=KDE;GNOME;\n",
|
||||
"/etc/xdg/autostart/gnome-keyring.desktop": app("keyring", "/usr/bin/gnome-keyring-daemon --start", "") + "OnlyShowIn=GNOME;Unity;\n",
|
||||
"/etc/xdg/autostart/geoclue.desktop": app("geoclue", "/usr/lib/geoclue-2.0/demos/agent", "") + "NotShowIn=i3;\n",
|
||||
"/etc/xdg/autostart/print.desktop": app("print", "system-config-printer-applet", "") + "TryExec=system-config-printer-applet\n",
|
||||
"/etc/xdg/autostart/polychromatic.desktop": app("poly", "polychromatic-helper --autostart", ""),
|
||||
"/etc/xdg/autostart/slack-too.desktop": app("Slack", "/usr/bin/slack", ""),
|
||||
home + "/.config/i3/config": "exec --no-startup-id dex --autostart --environment i3\nexec --no-startup-id picom\n",
|
||||
home + "/.config/i3/config.d/90-x.conf": "exec_always --no-startup-id polychromatic-tray-applet\n",
|
||||
"/usr/bin/slack": "#!/bin/sh\n",
|
||||
"/usr/bin/picom": "#!/bin/sh\n",
|
||||
"/usr/bin/nm-applet": "#!/bin/sh\n",
|
||||
"/usr/bin/nextcloud": "#!/bin/sh\n",
|
||||
"/usr/bin/polychromatic-helper": "#!/bin/sh\n",
|
||||
"/proc/101/cmdline": "/usr/lib/slack/slack --type=renderer --enable-crash-reporter",
|
||||
"/proc/101/comm": "slack\n",
|
||||
"/proc/102/cmdline": "picom\x00",
|
||||
"/proc/103/cmdline": "python3\x00/usr/bin/polychromatic-helper\x00--autostart\x00",
|
||||
"/proc/104/cmdline": "nextcloud-sync-helper\x00",
|
||||
"/proc/self/cmdline": "not a process\x00",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAutostartIsDecidedAsDexDecides(t *testing.T) {
|
||||
m := autostartMachine(t)
|
||||
got := map[string]Autostart{}
|
||||
for _, a := range m.Autostarts("i3") {
|
||||
got[a.Scope+"/"+a.ID] = a
|
||||
}
|
||||
want := map[string]bool{
|
||||
"user/slack.desktop": true, "user/blueman.desktop": false, "user/nextcloud.desktop": true,
|
||||
"system/blueman.desktop": false, "system/picom.desktop": true, "system/nm-applet.desktop": true,
|
||||
"system/gnome-keyring.desktop": false, "system/geoclue.desktop": false, "system/print.desktop": false,
|
||||
"system/polychromatic.desktop": true, "system/slack-too.desktop": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%d entries: %v", len(got), got)
|
||||
}
|
||||
for k, starts := range want {
|
||||
if got[k].DexStarts != starts {
|
||||
t.Errorf("%s: dex starts %v (%s)", k, got[k].DexStarts, got[k].Why)
|
||||
}
|
||||
}
|
||||
if a := got["system/blueman.desktop"]; a.OverriddenBy != "~/.config/autostart/blueman.desktop" || got["user/blueman.desktop"].Overrides != "/etc/xdg/autostart/blueman.desktop" {
|
||||
t.Errorf("the user's entry hides the system's of the same name: %+v", a)
|
||||
}
|
||||
if a := got["user/nextcloud.desktop"]; !strings.Contains(a.Why, "does not stop dex") {
|
||||
t.Errorf("%s", a.Why)
|
||||
}
|
||||
if a := got["system/print.desktop"]; !strings.Contains(a.Why, "TryExec") {
|
||||
t.Errorf("%s", a.Why)
|
||||
}
|
||||
if a := got["system/picom.desktop"]; a.AlsoStarted != "~/.config/i3/config:2" || a.Running != 1 {
|
||||
t.Errorf("picom: %+v", a)
|
||||
}
|
||||
if got["user/slack.desktop"].Running != 1 || got["system/polychromatic.desktop"].Running != 1 || got["user/nextcloud.desktop"].Running != 0 {
|
||||
t.Errorf("running: slack %d, polychromatic %d, nextcloud %d", got["user/slack.desktop"].Running,
|
||||
got["system/polychromatic.desktop"].Running, got["user/nextcloud.desktop"].Running)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCheckFindsTwoStartersAndAMissingProgram(t *testing.T) {
|
||||
m := autostartMachine(t)
|
||||
checks := map[string]Check{}
|
||||
for _, c := range m.Check("i3")["checks"].([]Check) {
|
||||
checks[c.Name] = c
|
||||
}
|
||||
twice := checks["no program is started twice at login"]
|
||||
if twice.OK || !strings.Contains(twice.Detail, "picom: by /etc/xdg/autostart/picom.desktop and by ~/.config/i3/config:2") ||
|
||||
!strings.Contains(twice.Detail, "slack: by ~/.config/autostart/slack.desktop and /etc/xdg/autostart/slack-too.desktop") {
|
||||
t.Fatalf("%+v", twice)
|
||||
}
|
||||
// blueman's system entry is hidden by the user's, so dex starts nothing that is missing.
|
||||
if c := checks["every program dex starts is installed"]; !c.OK {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
if c := checks["user-dirs.dirs is the mesh's"]; c.OK {
|
||||
t.Fatalf("no file, yet %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
en_US
|
||||
@@ -0,0 +1,5 @@
|
||||
module xdg
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user