feh: the wallpaper as a module, its image an archive of its own (hq ADR 0208, ADR 0205)

~/.fehbg stops pointing into the predecessor's tree; the session's xinitrc slot
runs it once; Go tools set (for the session) and current.
This commit is contained in:
jochen
2026-10-04 13:15:39 +02:00
parent c42cd285e2
commit 91c6fa6fce
15 changed files with 1401 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# feh
The wallpaper as a module (novox/hq ADR 0208, research 026/05).
- Installs `feh` and requires `x11-display` on its own machine. It holds no seat: a wallpaper is not a
role anything else calls.
- **Carries the wallpaper itself.** `wallpaper/default.jpg` is built into an archive of the module
(ADR 0205) and unpacked into `~/.local/share/feh/wallpapers/`, which the module owns.
- Owns `~/.fehbg`, which sets that image, filled, on every monitor, without rewriting itself
(`--no-fehbg`).
- Runs `~/.fehbg` once per session, from the session's start (the `xinitrc` slot `normal`).
- Binds `$mod+Shift+b` to the same file, as its own i3 drop-in (`50-feh.conf`): the declared wallpaper
back, after a monitor change.
## Tools
| tool | does |
|---|---|
| `feh_set` | set images (one for all monitors, or one each) in a mode: fill, center, max, scale, tile. For this session; the declared wallpaper returns at the next login |
| `feh_current` | the declared wallpaper (from `~/.fehbg`) and the one `feh_set` put up in this session |
`feh_set` never writes `~/.fehbg`. A wallpaper that should stay is a change to this module, or a
setting once issue 168 closes, not a file the next push would overwrite.
## What it improves on what was found
- **The wallpaper no longer lives in the predecessor's tree.** `~/.fehbg` pointed into a directory
of the retired predecessor's. Deleting that directory would have left the desktop black, silently.
- **One image file, the same on both workstations.** The image was byte-identical on both. It is now
the module's own.
## What it leaves as found
- The predecessor's wallpaper directory. It is part of the predecessor's tree, which goes as a whole.
## Migration (ADR 0182)
- The first push keeps the found `~/.fehbg` once, then writes the module's.
- Once the `xorg` module writes the session's start, delete the `~/.fehbg &` line from your own part
of `~/.xinitrc`.
- The image's origin is the predecessor's desktop module. Check that it may be redistributed before
this catalogue is published anywhere public.
## Blockers
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
them as unknown.
+97
View File
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || 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 is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
+53
View File
@@ -0,0 +1,53 @@
// feh's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the wallpaper's tools, served by the
// node's runtime as the operator account.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
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: "feh_set",
Description: "Set the wallpaper in the operator's session: one image for every monitor, or one per " +
"monitor in the X screen order, filled, centred, scaled to fit, stretched or tiled. Lasts until the " +
"next session start, when the declared wallpaper returns; the declared one is untouched.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "image files on this machine, absolute or under the account's home; one per monitor, or one for all"},
"mode": map[string]any{"type": "string", "enum": modes, "description": "default fill"},
},
"required": []string{"images"},
},
Run: func(args map[string]any) (any, error) {
images, err := texts(args, "images")
if err != nil {
return nil, err
}
mode, err := text(args, "mode", false)
if err != nil {
return nil, err
}
return Set(images, mode)
},
},
{
Name: "feh_current",
Description: "The wallpaper: the declared one the session start sets (images and mode, read from " +
"~/.fehbg), and the one feh_set put up in this session, if any.",
Run: func(map[string]any) (any, error) { return Current() },
},
}
}
@@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,79 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// feh's shape (novox/hq ADR 0208): no seat; it requires the X display on its own machine, carries
// the wallpaper as its own archive (ADR 0205), owns ~/.fehbg pointing at it, and sets it once from
// the session's start.
func TestItRequiresTheXDisplayAndClaimsNothing(t *testing.T) {
m := readManifest(t)
if m.Module != "feh" || m.Seats != nil || m.Claims != nil {
t.Fatalf("module %q, seats %v, claims %v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"feh"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestTheWallpaperIsTheModulesOwnArchive(t *testing.T) {
m := readManifest(t)
a := m.resource(t, "wallpapers")
if a["type"] != "archive" || a["artifact"] != "wallpapers" || a["path"] != "${machine:account-home}/.local/share/feh/wallpapers" || a["owner"] != "${machine:account}" {
t.Fatalf("%v", a)
}
found := false
for _, art := range m.Build.Artifacts {
if art["name"] == "wallpapers" && art["kind"] == "archive" && art["from"] == "wallpaper" {
found = true
}
}
if !found {
t.Fatal("no archive artifact built from wallpaper/")
}
info, err := os.Stat(filepath.Join("..", "..", "wallpaper", "default.jpg"))
if err != nil || info.Size() == 0 {
t.Fatalf("the image: %v", err)
}
}
func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "fehbg", "files/fehbg")
f := m.resource(t, "fehbg")
if f["path"] != "${machine:account-home}/.fehbg" || f["mode"] != "0755" {
t.Fatalf("%v", f)
}
if c := f["content"].(string); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") {
t.Fatalf("%s", c)
}
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || strings.Count(m.Shell[0].Code, `"$HOME/.fehbg"`) != 1 {
t.Fatalf("%+v", m.Shell)
}
}
func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) {
m := readManifest(t)
m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
t.Fatalf("path: %v", p)
}
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
t.Fatalf("%s", c)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
+423
View File
@@ -0,0 +1,423 @@
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
// the account that is part of the session (the window manager first), the same thing `loginctl` and
// a person's own shell would point at, and says where it found them.
//
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
// die with it.
//
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
// second consumer outside the desktop wants it.
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Where the session is looked for. Variables so a test can point them at a fake tree.
var (
procRoot = "/proc"
runUserDir = "/run/user"
x11Sockets = "/tmp/.X11-unix"
)
// sessionHolders are the processes whose environment is the session's, best first: the window
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
// Session is what a tool needs to reach the operator's desktop.
type Session struct {
UID int `json:"uid"`
Display string `json:"display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
Wayland string `json:"wayland_display,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
SessionID string `json:"session_id,omitempty"`
I3Sock string `json:"i3sock,omitempty"`
// From says where the values were found: the tool's own environment, a process, or the socket.
From string `json:"from"`
}
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
var ErrNoSession = errors.New("no graphical session")
// ErrTimedOut is what run answers for a command ended because it ran past its time.
var ErrTimedOut = errors.New("timed out")
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
var ErrNoBus = errors.New("no session bus")
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
// findSession finds the graphical session of the account this tool runs as, or answers
// ErrNoSession with what it looked at.
func findSession() (Session, error) {
s := findEnvironment()
if s.Display == "" && s.Wayland == "" {
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
}
return s, nil
}
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
// is running.
func findBus() (Session, error) {
s := findEnvironment()
if s.Bus == "" {
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
}
return s, nil
}
func findEnvironment() Session {
uid := os.Getuid()
s := Session{UID: uid}
own := map[string]string{}
for _, k := range sessionKeys {
own[k] = os.Getenv(k)
}
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
s.fill(own)
s.From = "the tool's own environment"
} else if pid, comm, env, ok := sessionProcess(uid); ok {
s.fill(env)
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
} else if display, ok := lonelyX11Socket(); ok {
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
s.Display, s.XAuthority = display, a
s.From = "the X server socket and the account's ~/.Xauthority"
}
s.fill(own)
} else {
s.fill(own)
s.From = "nothing: no session found"
}
// The bus and the runtime directory are the account's, whether or not the process named them.
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
if s.RuntimeDir == "" && exists(runtime) {
s.RuntimeDir = runtime
}
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s
}
func (s *Session) fill(env map[string]string) {
set := func(dst *string, key string) {
if *dst == "" {
*dst = env[key]
}
}
set(&s.Display, "DISPLAY")
set(&s.XAuthority, "XAUTHORITY")
set(&s.Wayland, "WAYLAND_DISPLAY")
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
set(&s.SessionID, "XDG_SESSION_ID")
set(&s.I3Sock, "I3SOCK")
}
// sessionProcess is the best process of this uid whose environment names a display.
func sessionProcess(uid int) (int, string, map[string]string, bool) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return 0, "", nil, false
}
type candidate struct {
pid int
comm string
env map[string]string
rank int
}
var found []candidate
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
env := parseEnviron(raw)
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
break
}
}
found = append(found, candidate{pid, comm, env, rank})
}
if len(found) == 0 {
return 0, "", nil, false
}
sort.Slice(found, func(i, j int) bool {
if found[i].rank != found[j].rank {
return found[i].rank < found[j].rank
}
return found[i].pid > found[j].pid // the newer of two equals
})
best := found[0]
return best.pid, best.comm, best.env, true
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
func ownerOf(path string) (int, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return int(st.Uid), true
}
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
func lonelyX11Socket() (string, bool) {
entries, err := os.ReadDir(x11Sockets)
if err != nil {
return "", false
}
var displays []string
for _, e := range entries {
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
if _, err := strconv.Atoi(n); err == nil {
displays = append(displays, ":"+n)
}
}
}
if len(displays) != 1 {
return "", false
}
return displays[0], true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Env is this process's environment with the session's variables in place of its own.
func (s Session) Env() []string {
drop := map[string]bool{}
for _, k := range sessionKeys {
drop[k] = true
}
var env []string
for _, kv := range os.Environ() {
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
continue
}
env = append(env, kv)
}
add := func(k, v string) {
if v != "" {
env = append(env, k+"="+v)
}
}
add("DISPLAY", s.Display)
add("XAUTHORITY", s.XAuthority)
add("WAYLAND_DISPLAY", s.Wayland)
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
add("XDG_RUNTIME_DIR", s.RuntimeDir)
add("XDG_SESSION_ID", s.SessionID)
add("I3SOCK", s.I3Sock)
return env
}
// mostOutput bounds what a command may answer with, per stream.
const mostOutput = 256 << 10
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr,omitempty"`
Code int `json:"code"`
Truncated bool `json:"truncated,omitempty"`
}
// run runs a command in the session's environment, its input given, ended with everything it
// started after timeout. A command that is not installed is an error naming it; one that exits
// non-zero is a Result with its code, for the caller to judge.
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
path, err := exec.LookPath(name)
if err != nil {
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errOut capped
cmd.Stdout, cmd.Stderr = &out, &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return Result{}, fmt.Errorf("%s: %w", name, err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err = <-done:
case <-time.After(timeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return Result{Stdout: out.String(), Stderr: errOut.String()},
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
}
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else if err != nil {
return r, fmt.Errorf("%s: %w", name, err)
}
return r, nil
}
// detach starts a long-lived program under the account's own service manager, as a transient unit
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
// running under the same name is stopped first, so a fixed name means "at most one".
func (s Session) detach(unit string, args ...string) error {
if s.RuntimeDir == "" {
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
"cannot be reached", ErrNoBus)
}
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--")
call = append(call, args...)
r, err := s.run(10*time.Second, "", "systemd-run", call...)
if err != nil {
return err
}
if r.Code != 0 {
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
}
return nil
}
// uniqueUnit is a transient unit name that will not collide with an earlier one.
func uniqueUnit(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
type capped struct {
bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := mostOutput - c.Len(); room < len(p) {
if room > 0 {
c.Buffer.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.Buffer.Write(p)
}
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
func processesOf(comm string) []int {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil
}
uid := os.Getuid()
var pids []int
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
if readTrimmed(filepath.Join(dir, "comm")) == comm {
pids = append(pids, pid)
}
}
sort.Ints(pids)
return pids
}
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
func signalAll(comm string, sig syscall.Signal) []int {
var reached []int
for _, pid := range processesOf(comm) {
if syscall.Kill(pid, sig) == nil {
reached = append(reached, pid)
}
}
return reached
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
// none of the test process's own session variables, and gives back the root.
func fakeMachine(t *testing.T) string {
t.Helper()
root := t.TempDir()
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
for _, k := range sessionKeys {
t.Setenv(k, "")
}
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
return root
}
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
t.Helper()
dir := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
t.Fatal(err)
}
}
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
s, err := findSession()
if err != nil {
t.Fatal(err)
}
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
t.Fatalf("the window manager's environment: %+v", s)
}
for _, kv := range s.Env() {
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
t.Fatal("a variable of the session process that is not a session variable was handed on")
}
}
}
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
s, err := findSession()
if err != nil || s.Display != ":2" {
t.Fatalf("the newest of two equals: %+v, %v", s, err)
}
}
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
_, err := findSession()
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
t.Fatalf("no session: %v", err)
}
}
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
root := fakeMachine(t)
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findSession()
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
t.Fatalf("socket and authority: %+v, %v", s, err)
}
}
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory is no bus: %v", err)
}
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findBus()
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
t.Fatalf("bus: %+v, %v", s, err)
}
env := strings.Join(s.Env(), "\n")
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
t.Fatalf("the bus is handed on: %s", env)
}
}
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
fakeMachine(t)
s := Session{}
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("result: %+v, %v", r, err)
}
start := time.Now()
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
}
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
t.Fatalf("a missing program: %v", err)
}
}
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
fakeMachine(t)
bin := fakeBinaries(t, map[string]string{
"systemctl": `echo "systemctl $*" >> "$LOG"`,
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
})
log := filepath.Join(bin, "log")
t.Setenv("LOG", log)
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(log)
want := "systemctl --user stop picom-session.service\n" +
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
if string(got) != want {
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
}
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory: %v", err)
}
}
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
func fakeBinaries(t *testing.T, scripts map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// modes are feh's background modes, by the word feh_set takes.
var modes = []string{"fill", "center", "max", "scale", "tile"}
// Wallpaper is images and how they are laid on the screens.
type Wallpaper struct {
Images []string `json:"images"`
Mode string `json:"mode"`
At string `json:"at,omitempty"`
}
func fehbg() string { return filepath.Join(operatorHome(), ".fehbg") }
// sessionRecord is where feh_set notes what it put up, for feh_current: in the runtime directory,
// so it lasts exactly as long as the login, like the wallpaper itself.
func sessionRecord(s Session) string {
if s.RuntimeDir == "" {
return ""
}
return filepath.Join(s.RuntimeDir, "feh", "current.json")
}
// SetResult is what feh_set answers.
type SetResult struct {
Wallpaper
Note string `json:"note"`
}
// Set puts images up as the wallpaper for this session.
func Set(images []string, mode string) (SetResult, error) {
if mode == "" {
mode = "fill"
}
known := false
for _, m := range modes {
known = known || m == mode
}
if !known {
return SetResult{}, fmt.Errorf("mode %q is one of %s", mode, strings.Join(modes, ", "))
}
if len(images) == 0 {
return SetResult{}, errors.New("images is required: at least one image")
}
var paths []string
for _, img := range images {
p := img
if strings.HasPrefix(p, "~/") {
p = filepath.Join(operatorHome(), p[2:])
}
if !filepath.IsAbs(p) {
p = filepath.Join(operatorHome(), p)
}
info, err := os.Stat(p)
if err != nil {
return SetResult{}, fmt.Errorf("image %s: %w", img, err)
}
if info.IsDir() {
return SetResult{}, fmt.Errorf("image %s is a directory", img)
}
paths = append(paths, p)
}
s, err := findSession()
if err != nil {
return SetResult{}, err
}
args := append([]string{"--no-fehbg", "--bg-" + mode}, paths...)
r, err := s.run(15*time.Second, "", "feh", args...)
if err != nil {
return SetResult{}, err
}
if r.Code != 0 {
return SetResult{}, fmt.Errorf("feh: %s", strings.TrimSpace(r.Stderr))
}
w := Wallpaper{Images: paths, Mode: mode, At: time.Now().Format(time.RFC3339)}
if rec := sessionRecord(s); rec != "" {
if err := os.MkdirAll(filepath.Dir(rec), 0o700); err == nil {
raw, _ := json.Marshal(w)
_ = os.WriteFile(rec, raw, 0o600)
}
}
return SetResult{Wallpaper: w, Note: "for this session; the declared wallpaper returns at the next login"}, nil
}
// CurrentResult is what feh_current answers.
type CurrentResult struct {
Declared *Wallpaper `json:"declared"`
Session *Wallpaper `json:"session,omitempty"`
}
var bgMode = regexp.MustCompile(`--bg-(fill|center|max|scale|tile)\b`)
// Current is the declared wallpaper and the one set in this session.
func Current() (CurrentResult, error) {
var out CurrentResult
if raw, err := os.ReadFile(fehbg()); err == nil {
out.Declared = parseFehbg(string(raw), operatorHome())
}
if rec := sessionRecord(findEnvironment()); rec != "" {
if raw, err := os.ReadFile(rec); err == nil {
var w Wallpaper
if json.Unmarshal(raw, &w) == nil {
out.Session = &w
}
}
}
return out, nil
}
// parseFehbg reads the feh line of a ~/.fehbg: its mode and its images, with $HOME expanded.
func parseFehbg(script, home string) *Wallpaper {
for _, line := range strings.Split(script, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "feh ") {
continue
}
w := &Wallpaper{Images: []string{}}
if m := bgMode.FindStringSubmatch(line); m != nil {
w.Mode = m[1]
}
for _, word := range shellWords(line)[1:] {
if strings.HasPrefix(word, "-") {
continue
}
word = strings.ReplaceAll(strings.ReplaceAll(word, "${HOME}", home), "$HOME", home)
w.Images = append(w.Images, word)
}
return w
}
return nil
}
// shellWords splits a simple command line on blanks, honouring single and double quotes.
func shellWords(line string) []string {
var words []string
var cur strings.Builder
var quote byte
in := false
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case quote != 0 && c == quote:
quote = 0
case quote != 0:
cur.WriteByte(c)
case c == '\'' || c == '"':
quote, in = c, true
case c == ' ' || c == '\t':
if in {
words = append(words, cur.String())
cur.Reset()
in = false
}
default:
cur.WriteByte(c)
in = true
}
}
if in {
words = append(words, cur.String())
}
return words
}
@@ -0,0 +1,92 @@
package main
import (
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
const nobody = 4194400
func TestTheDeclaredWallpaperIsReadFromTheModulesFehbg(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "files", "fehbg"))
if err != nil {
t.Fatal(err)
}
w := parseFehbg(string(raw), "/home/op")
if w == nil || w.Mode != "fill" || !reflect.DeepEqual(w.Images, []string{"/home/op/.local/share/feh/wallpapers/default.jpg"}) {
t.Fatalf("%+v", w)
}
// The form feh writes itself, single-quoted, two monitors.
w = parseFehbg("#!/bin/sh\nfeh --no-fehbg --bg-center '/a b/one.png' '/two.png' \n", "/home/op")
if w.Mode != "center" || !reflect.DeepEqual(w.Images, []string{"/a b/one.png", "/two.png"}) {
t.Fatalf("%+v", w)
}
if parseFehbg("#!/bin/sh\n", "/h") != nil {
t.Fatal("a file without feh declares a wallpaper")
}
}
func TestSetPutsTheImagesUpForThisSessionAndCurrentSaysSo(t *testing.T) {
root := fakeMachine(t)
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil {
t.Fatal(err)
}
home := filepath.Join(root, "home")
for _, f := range []string{"Pictures/a.jpg", "Pictures/b.jpg"} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(home, f)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, f), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
}
src, _ := os.ReadFile(filepath.Join("..", "..", "files", "fehbg"))
if err := os.WriteFile(filepath.Join(home, ".fehbg"), src, 0o755); err != nil {
t.Fatal(err)
}
bin := fakeBinaries(t, map[string]string{"feh": `echo "$* DISPLAY=$DISPLAY" > "$LOG"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
got, err := Set([]string{"~/Pictures/a.jpg", "Pictures/b.jpg"}, "scale")
if err != nil || !strings.Contains(got.Note, "next login") {
t.Fatalf("%+v, %v", got, err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
want := "--no-fehbg --bg-scale " + filepath.Join(home, "Pictures/a.jpg") + " " + filepath.Join(home, "Pictures/b.jpg") + " DISPLAY=:1\n"
if string(asked) != want {
t.Fatalf("feh was asked %q, want %q", asked, want)
}
cur, err := Current()
if err != nil || cur.Declared == nil || cur.Session == nil || cur.Session.Mode != "scale" || len(cur.Session.Images) != 2 ||
cur.Declared.Images[0] != filepath.Join(home, ".local/share/feh/wallpapers/default.jpg") {
t.Fatalf("%+v, %v", cur, err)
}
}
func TestSetRefusesWhatFehCannotShow(t *testing.T) {
fakeMachine(t)
if _, err := Set([]string{"/nowhere.jpg"}, ""); err == nil {
t.Fatal("a missing image was accepted")
}
if _, err := Set([]string{"/"}, ""); err == nil {
t.Fatal("a directory was accepted")
}
if _, err := Set([]string{"/etc/hostname"}, "stretch"); err == nil {
t.Fatal("an unknown mode was accepted")
}
if _, err := Set(nil, ""); err == nil {
t.Fatal("no image was accepted")
}
f := filepath.Join(t.TempDir(), "x.jpg")
if err := os.WriteFile(f, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Set([]string{f}, ""); !errors.Is(err, ErrNoSession) {
t.Fatalf("without a session: %v", err)
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The
# session's start runs it, and so may anything that wants the declared wallpaper back. The image is
# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session
# without touching this file.
feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg"
+3
View File
@@ -0,0 +1,3 @@
# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.
bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg
+5
View File
@@ -0,0 +1,5 @@
module feh
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+71
View File
@@ -0,0 +1,71 @@
{
"module": "feh",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"feh_set",
"feh_current"
],
"shell": [
{
"for": "xinitrc",
"slot": "normal",
"code": "# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.\n\"$HOME/.fehbg\"\n"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "feh"
},
{
"id": "wallpapers",
"type": "archive",
"path": "${machine:account-home}/.local/share/feh/wallpapers",
"owner": "${machine:account}",
"artifact": "wallpapers"
},
{
"id": "fehbg",
"type": "file",
"path": "${machine:account-home}/.fehbg",
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n"
},
{
"id": "i3-bindings",
"type": "file",
"path": "${machine:account-home}/.config/i3/config.d/50-feh.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
}
],
"build": {
"artifacts": [
{
"name": "wallpapers",
"kind": "archive",
"from": "wallpaper"
},
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/feh-tools",
"binary": "feh-tools",
"loads": [
"feh-tools"
]
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB