From 838a6e510b414741bce742882e97b161ae62db85 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 13:01:25 +0200 Subject: [PATCH] dmenu: the package, which makes the notifier's menu work, and a menu tool (hq to-be 42 phase 2.7) Research 026/04 counted two plain dmenu calls failing with dmenu installed nowhere. Measured, they are one line on each workstation: dunst's `dmenu = /usr/bin/dmenu -p dunst:`. Installing the package fixes both by existing; the line stays the dunst module's. No claim: node-launcher is not in the controller's seat table yet, and the module says so. Two Go tools: menu, shaped like that seat's verb (chosen line, index, typed, cancelled, timed out within 25 s), and session. --- modules/dmenu/README.md | 56 +++ modules/dmenu/cmd/dmenu-tools/dmenu.go | 112 ++++++ modules/dmenu/cmd/dmenu-tools/dmenu_test.go | 108 ++++++ modules/dmenu/cmd/dmenu-tools/kit.go | 352 ++++++++++++++++++ modules/dmenu/cmd/dmenu-tools/kit_test.go | 147 ++++++++ modules/dmenu/cmd/dmenu-tools/main.go | 70 ++++ .../cmd/dmenu-tools/manifest_kit_test.go | 107 ++++++ modules/dmenu/cmd/dmenu-tools/session.go | 177 +++++++++ modules/dmenu/cmd/dmenu-tools/session_test.go | 94 +++++ modules/dmenu/go.mod | 5 + modules/dmenu/go.sum | 2 + modules/dmenu/module.json | 33 ++ 12 files changed, 1263 insertions(+) create mode 100644 modules/dmenu/README.md create mode 100644 modules/dmenu/cmd/dmenu-tools/dmenu.go create mode 100644 modules/dmenu/cmd/dmenu-tools/dmenu_test.go create mode 100644 modules/dmenu/cmd/dmenu-tools/kit.go create mode 100644 modules/dmenu/cmd/dmenu-tools/kit_test.go create mode 100644 modules/dmenu/cmd/dmenu-tools/main.go create mode 100644 modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go create mode 100644 modules/dmenu/cmd/dmenu-tools/session.go create mode 100644 modules/dmenu/cmd/dmenu-tools/session_test.go create mode 100644 modules/dmenu/go.mod create mode 100644 modules/dmenu/go.sum create mode 100644 modules/dmenu/module.json diff --git a/modules/dmenu/README.md b/modules/dmenu/README.md new file mode 100644 index 0000000..00e2e11 --- /dev/null +++ b/modules/dmenu/README.md @@ -0,0 +1,56 @@ +# dmenu + +The suckless menu, as a module (novox/hq research 026/04: "`dmenu` is a module of its own (official +repositories), able to hold the same seat on a machine that wants it"; to-be 42 phase 2 step 7). + +## Owns + +| what | where | +|---|---| +| `dmenu`, `dmenu_run`, `dmenu_path`, `stest` | package `dmenu` (official repositories) | + +## Improves: two broken calls work by existing + +Research 026/04 measured "plain `dmenu` in two places" in the operator's configuration, with dmenu +installed on neither workstation, so both failed. Measured again on 2026-10-04, the two are one line on +each workstation, in the notifier's configuration: + +- `~/.config/dunst/dunstrc`: `dmenu = /usr/bin/dmenu -p dunst:` + +It is the menu dunst opens to pick a notification's action or link. Every other menu in the +operator's scripts calls `rofi -dmenu`. Once this module installs the package, `/usr/bin/dmenu` exists +and that menu opens. The `dunst` module may later point the line at the launcher seat's command +instead (below). The line is that module's to own, so this module does not touch it. + +## The seat it would hold: not claimed yet + +Research 026/04 gives `node-launcher` to `rofi`, with a dmenu-compatible command as part of its +protocol, and lets `dmenu` hold the same seat on a machine that wants it. **The seat is not in the +controller's seat table yet**, and a claim on an unknown seat is refused. So this module claims +nothing. Its tool `dmenu_menu` is shaped like the seat's `menu` verb (research 026/05): show a list, +answer the chosen line. When the seat is recorded, the claim is one line here, serving `menu`. + +## Tools + +All answer JSON. `(r)` reads; `(d)` acts in the operator's session. + +| tool | what | +|---|---| +| `dmenu_menu` (d) | show up to 1000 choices, with a prompt, as one line or a vertical list, case-insensitive by default. Answers the chosen line and its index; a typed line that is not a choice (`typed`); `cancelled` when dismissed; `timed_out` when not answered in time (default 20 s, at most 25, below the runtime's 30 s call limit) | +| `dmenu_session` (r) | the session the menu would appear in and how it was found, or why there is none; dmenu's version | + +**Reaching the session** works as the `xclip` module's README describes: the runtime runs as the +account with no session words, and `session.go` finds the account's display and cookie from its own +processes. With no session, the tool says so and shows nothing. A keyboard held by another program +(a locked screen, an open menu) is answered as such. + +The menu draws in fontconfig's `monospace` at dmenu's default size, which the `fonts` module makes +JetBrains Mono Nerd Font. + +## What changes when it is assigned + +On both workstations, the `dmenu` package is installed, which neither has today. Nothing else. + +## Leaves as found + +The notifier's configuration, and every `rofi -dmenu` call in the operator's scripts. diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu.go b/modules/dmenu/cmd/dmenu-tools/dmenu.go new file mode 100644 index 0000000..4105d1e --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/dmenu.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered +// menu is answered as such rather than as a call the runtime gave up on. +const MostWait = 25 + +// MostChoices bounds the list. +const MostChoices = 1000 + +// Ask is one menu. +type Ask struct { + Choices []string + Prompt string + Lines int + CaseInsensitive bool + Timeout int +} + +// MenuAnswer is what dmenu_menu answers. +type MenuAnswer struct { + Chosen string `json:"chosen,omitempty"` + Index int `json:"index"` + Typed bool `json:"typed"` + Cancelled bool `json:"cancelled"` + TimedOut bool `json:"timed_out"` + Session Session `json:"session"` +} + +// Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and +// exits 0; it exits 1 with nothing printed when dismissed. +func Menu(a Ask) (MenuAnswer, error) { + if len(a.Choices) == 0 { + return MenuAnswer{}, fmt.Errorf("choices is required: at least one line") + } + if len(a.Choices) > MostChoices { + return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices) + } + for _, c := range a.Choices { + if strings.ContainsAny(c, "\n\r") { + return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c) + } + } + if strings.ContainsAny(a.Prompt, "\n\r") { + return MenuAnswer{}, fmt.Errorf("the prompt holds a line break") + } + s, err := findSession() + if err != nil { + return MenuAnswer{}, err + } + args := []string{} + if a.CaseInsensitive { + args = append(args, "-i") + } + if a.Lines > 0 { + args = append(args, "-l", strconv.Itoa(a.Lines)) + } + if a.Prompt != "" { + args = append(args, "-p", a.Prompt) + } + c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second} + r := run(c) + out := MenuAnswer{Index: -1, Session: s} + switch { + case r.Error == "timeout": + out.TimedOut = true + return out, nil + case r.Error != "": + return MenuAnswer{}, failure(c, r) + case strings.Contains(r.Stderr, "cannot open display"): + return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy) + case strings.Contains(r.Stderr, "cannot grab keyboard"): + return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)") + case r.Status == 1 && strings.TrimSpace(r.Stdout) == "": + out.Cancelled = true + return out, nil + case r.Status != 0: + return MenuAnswer{}, failure(c, r) + } + out.Chosen = strings.TrimRight(r.Stdout, "\r\n") + for i, ch := range a.Choices { + if ch == out.Chosen { + out.Index = i + break + } + } + out.Typed = out.Index < 0 + return out, nil +} + +// SessionCheck answers the session the menu would appear in, and dmenu's version. +func SessionCheck() (map[string]any, error) { + out := map[string]any{} + if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" { + out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr) + } else { + out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error() + } + s, err := findSession() + if err != nil { + out["found"], out["why"] = false, err.Error() + return out, nil + } + out["found"], out["session"] = true, s + return out, nil +} diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu_test.go b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go new file mode 100644 index 0000000..0e0e584 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestTheManifestIsThePackageAndClaimsNoSeatYet(t *testing.T) { + m := readManifest(t) + // holdsTheBundle also holds it to no claim: node-launcher is not in the controller's seat table + // yet (README). + holdsTheBundle(t, m, "dmenu") + if got := strings.Join(m.packages(), ","); got != "dmenu" || len(m.Resources) != 1 { + t.Errorf("packages %s, resources %v", got, m.Resources) + } +} + +func aSession(t *testing.T) { + t.Helper() + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"}) + aSocket(t, sockets, "X1") +} + +func TestMenuShowsTheChoicesInTheSessionAndAnswersTheOneTaken(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("Lock\n") }) + got, err := Menu(Ask{Choices: []string{"Shutdown", "Lock"}, Prompt: "power:", Lines: 5, CaseInsensitive: true, Timeout: 20}) + if err != nil || got.Chosen != "Lock" || got.Index != 1 || got.Typed || got.Cancelled || got.TimedOut { + t.Fatalf("%+v %v", got, err) + } + c := f.asked[0] + if f.lines()[0] != "dmenu -i -l 5 -p power:" || c.Stdin != "Shutdown\nLock\n" || c.Timeout != 20*time.Second { + t.Errorf("%v %+v", f.lines(), c) + } + if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" { + t.Errorf("env %v", c.Env) + } +} + +func TestMenuTellsTypedDismissedAndUnansweredApart(t *testing.T) { + aSession(t) + answer := ok("something else\n") + using(t, func(string, Cmd) Result { return answer }) + got, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.Typed || got.Index != -1 || got.Chosen != "something else" { + t.Errorf("typed: %+v %v", got, err) + } + answer = Result{Status: 1} + got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.Cancelled || got.Chosen != "" { + t.Errorf("dismissed: %+v %v", got, err) + } + answer = Result{Status: 124, Error: "timeout"} + got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5}) + if err != nil || !got.TimedOut { + t.Errorf("unanswered: %+v %v", got, err) + } + answer = Result{Status: 1, Stderr: "cannot grab keyboard\n"} + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "keyboard") { + t.Errorf("a held keyboard: %v", err) + } + answer = Result{Status: 1, Stderr: "cannot open display\n"} + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("a refusing display: %v", err) + } +} + +func TestMenuRefusesWhatCannotBeShownAndRunsNothingWithoutASession(t *testing.T) { + aSession(t) + f := using(t, func(string, Cmd) Result { return ok("") }) + for _, bad := range []Ask{{}, {Choices: []string{"a\nb"}}, {Choices: []string{"a"}, Prompt: "x\ny"}, {Choices: make([]string, MostChoices+1)}} { + if _, err := Menu(bad); err == nil { + t.Errorf("%+v accepted", bad) + } + } + aMachine(t, map[string]string{}) + if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Errorf("%v", err) + } + if len(f.asked) != 0 { + t.Errorf("ran %v", f.lines()) + } +} + +func TestTheWaitIsBelowTheRuntimesCallLimit(t *testing.T) { + if MostWait >= 30 { + t.Fatalf("a menu may stay open %d s; the runtime gives a call 30", MostWait) + } + n, _ := optWhole(map[string]any{"timeout_seconds": float64(600)}, "timeout_seconds", 20, 1, MostWait) + if n != MostWait { + t.Errorf("%d", n) + } +} + +func TestSessionCheckSaysTheVersionAndTheSession(t *testing.T) { + aSession(t) + using(t, func(string, Cmd) Result { return ok("dmenu-5.4\n") }) + got, err := SessionCheck() + if err != nil || got["dmenu"] != "dmenu-5.4" || got["found"] != true { + t.Fatalf("%v %v", got, err) + } + using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} }) + got, _ = SessionCheck() + if !strings.Contains(got["dmenu"].(string), "not installed") { + t.Errorf("%v", got) + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/kit.go b/modules/dmenu/cmd/dmenu-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/kit.go @@ -0,0 +1,352 @@ +package main + +// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd, +// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it +// keeps, and names a failure. A module is built from its own directory, so the file is copied rather +// than shared; a change to one copy is made to all eight. +// +// The rules it holds (novox/hq research 026/05, to-be 38 WP4): +// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that +// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such; +// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it +// started when it takes longer; +// - each stream is kept to 256 KiB, and the answer says when it was cut; +// - a failure is an error with what went wrong in it, never an empty answer. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds every command is held to. +const ( + CallTimeout = 20 * time.Second + MostOutput = 256 << 10 +) + +// Cmd is one command a tool runs. +type Cmd struct { + Name string + Args []string + // Stdin is written to the command's standard input when not empty. + Stdin string + // Env is added to this process's own environment. + Env []string + // Root says the command needs root: it is run through `sudo -n` when this process is not root. + Root bool + // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer. + Timeout time.Duration + // Detached is for a program that forks a child which outlives it, as xclip does to keep the + // selection: its streams go to files, because a pipe the child inherits would hold the call open + // until the child exits. + Detached bool +} + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Status int `json:"status"` + // Error is why it did not run to an answer: "not-found" when the program is not there, + // "timeout" when it was ended for taking too long, else the spawn error. + Error string `json:"error,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// Runner runs a command. Tests replace it; nothing else does. +type Runner func(Cmd) Result + +var ( + run Runner = execRun + euid = os.Geteuid +) + +// argv is the command as it is run: through sudo without a prompt when it needs root and this +// process is not root. +func argv(c Cmd) (string, []string) { + if c.Root && euid() != 0 { + return "sudo", append([]string{"-n", c.Name}, c.Args...) + } + return c.Name, c.Args +} + +// bounded keeps the first MostOutput bytes written to it and notes that more came. +type bounded struct { + b bytes.Buffer + cut bool +} + +func (w *bounded) Write(p []byte) (int, error) { + room := MostOutput - w.b.Len() + if room <= 0 { + w.cut = w.cut || len(p) > 0 + return len(p), nil + } + if len(p) > room { + w.b.Write(p[:room]) + w.cut = true + return len(p), nil + } + return w.b.Write(p) +} + +func execRun(c Cmd) Result { + timeout := c.Timeout + if timeout <= 0 { + timeout = CallTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + name, args := argv(c) + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...) + if !c.Detached { + // Its own process group, so that ending it on a timeout ends what it started too. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + } + cmd.WaitDelay = 2 * time.Second + if c.Stdin != "" { + cmd.Stdin = strings.NewReader(c.Stdin) + } + var out, errs bounded + var outFile, errFile *os.File + if c.Detached { + var err error + if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(outFile.Name()) + defer outFile.Close() + if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil { + return Result{Status: 127, Error: err.Error()} + } + defer os.Remove(errFile.Name()) + defer errFile.Close() + cmd.Stdout, cmd.Stderr = outFile, errFile + } else { + cmd.Stdout, cmd.Stderr = &out, &errs + } + err := cmd.Run() + if c.Detached { + for _, f := range []struct { + file *os.File + into *bounded + }{{outFile, &out}, {errFile, &errs}} { + if _, e := f.file.Seek(0, io.SeekStart); e == nil { + _, _ = io.Copy(f.into, f.file) + } + } + } + r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut} + var exit *exec.ExitError + switch { + case err == nil: + case ctx.Err() == context.DeadlineExceeded: + r.Status, r.Error = 124, "timeout" + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist): + r.Status, r.Error = 127, "not-found" + case errors.As(err, &exit): + r.Status = exit.ExitCode() + default: + r.Status, r.Error = 127, err.Error() + } + return r +} + +// call runs a command and answers its result, or an error naming what went wrong. +func call(c Cmd) (Result, error) { + r := run(c) + if r.Status == 0 && r.Error == "" { + return r, nil + } + return r, failure(c, r) +} + +// failure names how a command failed: not installed, refused escalation, too slow, or its exit +// status with the end of what it said. +func failure(c Cmd, r Result) error { + program, _ := argv(c) + switch { + case r.Error == "not-found" && program == "sudo": + return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name) + case r.Error == "not-found": + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case r.Error == "timeout": + limit := c.Timeout + if limit <= 0 { + limit = CallTimeout + } + return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit) + case r.Error != "": + return fmt.Errorf("%s did not run: %s", c.Name, r.Error) + case program == "sudo" && strings.Contains(r.Stderr, "command not found"): + if hint, ok := providedBy[c.Name]; ok { + return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint) + } + return fmt.Errorf("%s is not installed on this machine", c.Name) + case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"): + return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)", + c.Name, firstLine(r.Stderr)) + } + said := tail(strings.TrimSpace(r.Stderr), 2000) + if said == "" { + said = tail(strings.TrimSpace(r.Stdout), 2000) + } + if said == "" { + said = "and said nothing" + } + return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} + +// lines are a command's output lines, blank ones dropped. +func lines(s string) []string { + out := []string{} + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + out = append(out, strings.TrimRight(l, "\r")) + } + } + return out +} + +// Arguments, read the way a tool's JSON arguments arrive. + +func text(args map[string]any, key string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return "", fmt.Errorf("%s is required", key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return "", fmt.Errorf("%s must not be empty", key) + } + return s, nil +} + +func optText(args map[string]any, key, def string) (string, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + if strings.TrimSpace(s) == "" { + return def, nil + } + return s, nil +} + +// optWhole reads a whole number, defaulted, refused below least and held to most. +func optWhole(args map[string]any, key string, def, least, most int) (int, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s must be a number", key) + } + } + if f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +func optFlag(args map[string]any, key string, def bool) (bool, error) { + v, ok := args[key] + if !ok || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +func optList(args map[string]any, key string) ([]string, error) { + v, ok := args[key] + if !ok || v == nil { + return nil, nil + } + items, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("%s must be a list of strings", key) + } + out := make([]string, 0, len(items)) + for _, it := range items { + s, ok := it.(string) + if !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("%s must be a list of non-empty strings", key) + } + out = append(out, s) + } + return out, nil +} + +// oneOf refuses a value outside a closed set. +func oneOf(key, value string, allowed ...string) error { + for _, a := range allowed { + if value == a { + return nil + } + } + return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value) +} + +// plainName refuses a name that could be read as an option or carries a path or a space: package, +// snap, application and printer names never do. +func plainName(key, value string) error { + if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") { + return fmt.Errorf("%s %q is not a plain name", key, value) + } + return nil +} diff --git a/modules/dmenu/cmd/dmenu-tools/kit_test.go b/modules/dmenu/cmd/dmenu-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/kit_test.go @@ -0,0 +1,147 @@ +package main + +// Tests of kit.go, the same in each workstation module. + +import ( + "strings" + "testing" + "time" +) + +// fake records the commands asked and answers each from a function of the command line. +type fake struct { + asked []Cmd + answer func(line string, c Cmd) Result +} + +func (f *fake) runner() Runner { + return func(c Cmd) Result { + f.asked = append(f.asked, c) + name, args := argv(c) + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + if f.answer == nil { + return Result{} + } + return f.answer(line, c) + } +} + +func (f *fake) lines() []string { + out := []string{} + for _, c := range f.asked { + name, args := argv(c) + out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " "))) + } + return out +} + +// using installs a fake runner and a non-root uid for one test. +func using(t *testing.T, answer func(line string, c Cmd) Result) *fake { + t.Helper() + f := &fake{answer: answer} + wasRun, wasUID := run, euid + run, euid = f.runner(), func() int { return 1000 } + t.Cleanup(func() { run, euid = wasRun, wasUID }) + return f +} + +func ok(stdout string) Result { return Result{Stdout: stdout} } + +func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" { + t.Fatalf("not root: %s %v", name, args) + } + if name, _ := argv(Cmd{Name: "x"}); name != "x" { + t.Fatalf("a read is run as the account: %s", name) + } + euid = func() int { return 0 } + if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" { + t.Fatalf("as root no sudo: %s", name) + } +} + +func TestKitAFailureIsNamedByHowItFailed(t *testing.T) { + was := euid + defer func() { euid = was }() + euid = func() int { return 1000 } + cases := []struct { + c Cmd + r Result + want string + }{ + {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"}, + {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"}, + {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"}, + {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"}, + {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"}, + } + for _, k := range cases { + err := failure(k.c, k.r) + if err == nil || !strings.Contains(err.Error(), k.want) { + t.Errorf("%+v: %v, want %q", k.r, err, k.want) + } + } +} + +func TestKitOutputIsBoundedAndSaysSo(t *testing.T) { + var w bounded + big := strings.Repeat("a", MostOutput+10) + n, _ := w.Write([]byte(big)) + if n != len(big) || w.b.Len() != MostOutput || !w.cut { + t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut) + } +} + +func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) { + r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}}) + if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("%+v", r) + } + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond}) + if r.Error != "timeout" { + t.Fatalf("a slow command: %+v", r) + } + r = execRun(Cmd{Name: "no-such-program-anywhere"}) + if r.Error != "not-found" { + t.Fatalf("a missing program: %+v", r) + } + r = execRun(Cmd{Name: "cat", Stdin: "given"}) + if r.Stdout != "given" { + t.Fatalf("stdin: %+v", r) + } + start := time.Now() + r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true}) + if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second { + t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start)) + } +} + +func TestKitArgumentsAreReadStrictly(t *testing.T) { + args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if _, err := text(args, "missing"); err == nil { + t.Error("a missing required string") + } + if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 { + t.Errorf("held to most: %d", n) + } + if _, err := optWhole(args, "n", 1, 6, 9); err == nil { + t.Error("below least") + } + if _, err := optWhole(args, "f", 1, 0, 9); err == nil { + t.Error("a fraction") + } + if l, _ := optList(args, "l"); len(l) != 2 { + t.Errorf("list: %v", l) + } + if b, _ := optFlag(args, "b", false); !b { + t.Error("flag") + } + if err := plainName("name", "--all"); err == nil { + t.Error("an option as a name") + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/main.go b/modules/dmenu/cmd/dmenu-tools/main.go new file mode 100644 index 0000000..d98a193 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/main.go @@ -0,0 +1,70 @@ +// The dmenu module's tool (novox/hq research 026/04, 026/05): show the operator a menu of choices in +// the graphical session and answer the one chosen — the dmenu-compatible command as a tool. A Go +// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account +// and reaches the account's X session as session.go finds it; with no session, it says so. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "dmenu": "the dmenu package, which this module installs", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "dmenu_menu", + Description: "Show the operator a menu of choices on their screen and answer the line chosen, its index, or " + + "that the menu was dismissed or not answered in time (default 20 s, at most 25). The operator may also " + + "type a line that is not a choice. Needs the operator's graphical session. (d)", + Input: map[string]any{ + "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the lines to choose from, at most 1000, none with a line break"}, + "prompt": map[string]any{"type": "string", "description": "a prompt shown left of the input"}, + "lines": map[string]any{"type": "integer", "description": "show the choices as a vertical list of this many lines (default 0: one horizontal line; at most 40)"}, + "case_insensitive": map[string]any{"type": "boolean", "description": "match what is typed regardless of case (default true)"}, + "timeout_seconds": map[string]any{"type": "integer", "description": "close the menu unanswered after this long (default 20, at most 25)"}, + }, + Run: func(args map[string]any) (any, error) { + choices, err := optList(args, "choices") + if err != nil { + return nil, err + } + prompt, err := optText(args, "prompt", "") + if err != nil { + return nil, err + } + n, err := optWhole(args, "lines", 0, 0, 40) + if err != nil { + return nil, err + } + ci, err := optFlag(args, "case_insensitive", true) + if err != nil { + return nil, err + } + timeout, err := optWhole(args, "timeout_seconds", 20, 1, MostWait) + if err != nil { + return nil, err + } + return Menu(Ask{Choices: choices, Prompt: prompt, Lines: n, CaseInsensitive: ci, Timeout: timeout}) + }, + }, + { + Name: "dmenu_session", + Description: "Which X session the menu would appear in and how it was found, or why there is none; and dmenu's version. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return SessionCheck() }, + }, + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go b/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go @@ -0,0 +1,107 @@ +package main + +// manifest_kit_test.go is the same file in each workstation module: it reads the module's +// definition so the module's own tests can hold it to what it says. + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + 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.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + return nil +} + +// packages are the packages the module installs, sorted. +func (m manifest) packages() []string { + out := []string{} + for _, r := range m.Resources { + if r["type"] == "package" && r["absent"] != true { + out = append(out, r["package"].(string)) + } + } + sort.Strings(out) + return out +} + +// services are the units the module declares, by unit name. +func (m manifest) services() map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range m.Resources { + if r["type"] == "service" { + out[r["unit"].(string)] = r + } + } + return out +} + +// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered +// is listed and nothing else, each named _…, and the artifact builds this command. +func holdsTheBundle(t *testing.T, m manifest, prefix string) { + t.Helper() + registered := []string{} + for _, tool := range tools() { + registered = append(registered, tool.Name) + if !strings.HasPrefix(tool.Name, prefix+"_") { + t.Errorf("tool %s is not named %s_…", tool.Name, prefix) + } + if tool.Description == "" || tool.Run == nil || tool.Input == nil { + t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name) + } + } + if strings.Join(registered, ",") != strings.Join(m.Tools, ",") { + t.Errorf("registered %v, listed %v", registered, m.Tools) + } + if len(m.Build.Artifacts) != 1 { + t.Fatalf("one artifact, got %d", len(m.Build.Artifacts)) + } + cwd, _ := os.Getwd() + binary := filepath.Base(cwd) + a := m.Build.Artifacts[0] + want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary} + for k, v := range want { + if a[k] != v { + t.Errorf("artifact %s = %v, want %v", k, a[k], v) + } + } + if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary { + t.Errorf("artifact loads %v, want [%s]", a["loads"], binary) + } + if m.Claims != nil || m.Seats != nil { + t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats) + } +} diff --git a/modules/dmenu/cmd/dmenu-tools/session.go b/modules/dmenu/cmd/dmenu-tools/session.go new file mode 100644 index 0000000..43ba119 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/session.go @@ -0,0 +1,177 @@ +package main + +// session.go is the same file in the bundles whose tools act in the operator's graphical session +// (xclip, dmenu): how a process the node's tool runtime launched reaches that session. +// +// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the +// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X +// server accepts a client that names its display and presents the cookie in the authority file, and +// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is +// readable by the account. So the session is found, not configured: +// +// 1. the process's own DISPLAY, when the runtime happens to have one; +// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from +// /proc//environ (the window manager's, by preference), whose socket exists; +// 3. else the only X socket there is, with the authority file in the account's home. +// +// When none is found the tool says that no graphical session of the account is running, and does +// nothing. + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" +) + +// Session is the operator's X session as a tool reaches it. +type Session struct { + Display string `json:"display"` + XAuthority string `json:"xauthority,omitempty"` + // FoundBy says how: "environment", "process ()" or "socket". + FoundBy string `json:"found_by"` +} + +// Env is what a command needs to reach the session. +func (s Session) Env() []string { + env := []string{"DISPLAY=" + s.Display} + if s.XAuthority != "" { + env = append(env, "XAUTHORITY="+s.XAuthority) + } + return env +} + +// Where the session is looked for. Tests point these at a tree of their own. +var ( + procRoot = "/proc" + x11Sockets = "/tmp/.X11-unix" + getenv = os.Getenv + myUID = os.Getuid +) + +// sessionWMs are the programs whose environment is the session's own, preferred over any other +// process's (a terminal's child may carry a stale or forwarded DISPLAY). +var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true} + +func accountHome() string { + if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + if h := strings.TrimSpace(getenv("HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one. +func socketOf(display string) string { + if !strings.HasPrefix(display, ":") { + return "" + } + n := strings.TrimPrefix(display, ":") + if i := strings.IndexByte(n, '.'); i >= 0 { + n = n[:i] + } + if _, err := strconv.Atoi(n); err != nil { + return "" + } + return filepath.Join(x11Sockets, "X"+n) +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// findSession answers the account's X session, or an error saying there is none. +func findSession() (Session, error) { + if d := strings.TrimSpace(getenv("DISPLAY")); d != "" { + if s := socketOf(d); s == "" || exists(s) { + return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil + } + } + type seen struct { + Session + wm bool + count int + } + found := map[string]*seen{} + entries, _ := os.ReadDir(procRoot) + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil || !e.IsDir() { + continue + } + dir := filepath.Join(procRoot, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + var display, auth string + for _, kv := range strings.Split(string(raw), "\x00") { + switch { + case strings.HasPrefix(kv, "DISPLAY="): + display = strings.TrimPrefix(kv, "DISPLAY=") + case strings.HasPrefix(kv, "XAUTHORITY="): + auth = strings.TrimPrefix(kv, "XAUTHORITY=") + } + } + if display == "" { + continue + } + if s := socketOf(display); s == "" || !exists(s) { + continue + } + comm, _ := os.ReadFile(filepath.Join(dir, "comm")) + name := strings.TrimSpace(string(comm)) + key := display + "\x00" + auth + if found[key] == nil { + found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}} + } + f := found[key] + f.count++ + if sessionWMs[name] && !f.wm { + f.wm = true + f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name) + } + } + if len(found) > 0 { + all := make([]*seen, 0, len(found)) + for _, f := range found { + all = append(all, f) + } + sort.Slice(all, func(i, k int) bool { + if all[i].wm != all[k].wm { + return all[i].wm + } + if all[i].count != all[k].count { + return all[i].count > all[k].count + } + return all[i].Display < all[k].Display + }) + return all[0].Session, nil + } + sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*")) + if len(sockets) == 1 { + s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"} + if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) { + s.XAuthority = a + } + return s, nil + } + if len(sockets) > 1 { + return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets) + } + return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets) +} diff --git a/modules/dmenu/cmd/dmenu-tools/session_test.go b/modules/dmenu/cmd/dmenu-tools/session_test.go new file mode 100644 index 0000000..707e754 --- /dev/null +++ b/modules/dmenu/cmd/dmenu-tools/session_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// aMachine gives findSession a /proc and an X socket directory of the test's own. +func aMachine(t *testing.T, env map[string]string) (proc, sockets string) { + t.Helper() + root := t.TempDir() + proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11") + for _, d := range []string{proc, sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv + procRoot, x11Sockets = proc, sockets + getenv = func(k string) string { return env[k] } + t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv }) + return proc, sockets +} + +func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644) +} + +func aSocket(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) { + proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"}) + aSocket(t, sockets, "X1") + aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale") + aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority") + aProcess(t, proc, 6, "sshd", "PATH=/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") { + t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s) + } + if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" { + t.Fatalf("env %s", got) + } +} + +func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) { + home := t.TempDir() + _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600) + _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home}) + aSocket(t, sockets, "X0") + s, err := findSession() + if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) { + _, sockets := aMachine(t, map[string]string{}) + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("none: %v", err) + } + aSocket(t, sockets, "X0") + aSocket(t, sockets, "X1") + if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") { + t.Fatalf("two: %v", err) + } +} + +func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) { + _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"}) + aSocket(t, sockets, "X2") + s, err := findSession() + if err != nil || s.Display != ":2" || s.FoundBy != "environment" { + t.Fatalf("%+v %v", s, err) + } +} diff --git a/modules/dmenu/go.mod b/modules/dmenu/go.mod new file mode 100644 index 0000000..8cf0001 --- /dev/null +++ b/modules/dmenu/go.mod @@ -0,0 +1,5 @@ +module dmenu + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/dmenu/go.sum b/modules/dmenu/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/dmenu/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/dmenu/module.json b/modules/dmenu/module.json new file mode 100644 index 0000000..aeeb3ce --- /dev/null +++ b/modules/dmenu/module.json @@ -0,0 +1,33 @@ +{ + "module": "dmenu", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "dmenu_menu", + "dmenu_session" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dmenu" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dmenu-tools", + "binary": "dmenu-tools", + "loads": [ + "dmenu-tools" + ] + } + ] + } +}