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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module dmenu
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user