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.
This commit is contained in:
jochen
2026-10-04 13:02:23 +02:00
parent b1b7e58e4b
commit 838a6e510b
12 changed files with 1263 additions and 0 deletions
+56
View File
@@ -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.
+112
View File
@@ -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
}
+108
View File
@@ -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)
}
}
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+70
View File
@@ -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() },
},
}
}
@@ -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 <prefix>_…, 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)
}
}
+177
View File
@@ -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/<pid>/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 <pid> (<name>)" 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)
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module dmenu
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+33
View File
@@ -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"
]
}
]
}
}