i3status-rust: the bars as a module, claiming node-bar (hq ADR 0208)

Owns both bars and their icons, the bar blocks as an i3 drop-in; the battery,
GPU and headset blocks leave (hardware and a person's devices), the weather needs
no key; the update count and the bar watchdog become module code, the watchdog
started once per session. Go tools reload, blocks, block-run and themes.
This commit is contained in:
jochen
2026-10-04 13:15:39 +02:00
parent 91c6fa6fce
commit 838e1c2616
18 changed files with 2108 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
# i3status-rust
The bars as a module (novox/hq ADR 0208, research 026/05).
- Installs `i3status-rust`, and `pacman-contrib` for `checkupdates`, which the update block uses.
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own
machine: its bars are i3bar's.
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
(`icons/custom-icons.toml`).
- Places the two `bar { }` blocks as its own i3 drop-in, `~/.config/i3/config.d/60-i3status-rust.conf`:
- the bottom bar shows the machine;
- the top bar shows the focused window and the tray, on the primary output.
- Places two programs in `~/.local/bin`:
- `i3status-updates`, the pending-updates block;
- `i3bar-watchdog`, which brings back a bar that died.
- Starts the watchdog once per session, from the session's start (the `xinitrc` slot `normal`). It ends
with the session's own process.
## Tools
| tool | does |
|---|---|
| `i3status_rust_reload` | the running bars re-read their files (i3status-rs restarts in place) |
| `i3status_rust_blocks` | each bar's blocks in order, kind and settings, and what each shows now (one run of the bar) |
| `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors |
| `i3status_rust_themes` | the themes on the machine and the one each bar uses |
## The battery, and what else was left out
A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208
§1), so the bottom bar here has none:
- **the battery,** which only the laptop has;
- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop;
- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's.
i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only,
so a hardware module has no way into this file yet. Two ways forward, for the operator to choose:
1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's
hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4.
2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for
example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware.
That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar.
Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned.
## What it improves on what was found
- **The weather needs no key.** The found block used a weather service with an API key written in
plain text in the file, and a fixed city. It now uses the Norwegian Meteorological Institute, which
needs no key, located automatically.
- **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It
counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the
launcher's menu, instead of a theme from a cloned theme repository.
- **The faces** are JetBrains Mono Nerd Font, not Hack.
- **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the
other. It runs on both, once per session, and ends with the session.
- The docker block's click, which ran a program installed on neither machine, is gone.
## What it leaves as found
- `~/.config/i3status-rust/scripts/` (a mouse battery script for one device).
- `~/scripts/pkg-updates` and `~/scripts/i3-bar-watchdog`, replaced here.
- The user unit `~/.config/systemd/user/i3-bar-watchdog.service` and its enablement link.
## Migration (ADR 0182)
1. **Revoke the weather API key** that was in the found `bottom-bar.toml`. It sat in plain text on
both workstations. The first push keeps the found file once and then writes the module's, which
has no key.
2. Before the first push, on the laptop: `systemctl --user disable --now i3-bar-watchdog.service`.
Otherwise two watchdogs run. Then delete the unit file, `~/scripts/i3-bar-watchdog` and
`~/scripts/pkg-updates`.
3. Until the `i3` module carries the main configuration, the found `~/.config/i3/config` still has its
own two `bar { }` blocks, and i3 shows four bars. The `i3` module's configuration has none.
## Blockers
- `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
`mctl` reads them as unknown.
- The battery block waits for one of the two ways above.
- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it
runs per session and ends with the session, as a session-start line already does.
- `pacman-contrib` is declared here. A future `pacman` module that wants `checkupdates` or `paccache`
takes it over, since a package is declared once per node.
@@ -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
}
@@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
var bars = []string{"top", "bottom"}
// A bar runs this long when a tool reads its values: long enough for every block's first update.
const runFor = 4 * time.Second
func configDir() string { return filepath.Join(operatorHome(), ".config", "i3status-rust") }
func barFile(bar string) string { return filepath.Join(configDir(), bar+"-bar.toml") }
func barsOf(bar string) ([]string, error) {
if bar == "" {
return bars, nil
}
for _, b := range bars {
if b == bar {
return []string{bar}, nil
}
}
return nil, fmt.Errorf("bar %q is top or bottom", bar)
}
// ReloadResult is what i3status_rust_reload answers.
type ReloadResult struct {
Reached []int `json:"reached"`
Note string `json:"note"`
}
// Reload restarts every running i3status-rs in place, which re-reads its file (SIGUSR2).
func Reload() ReloadResult {
reached := signalAll("i3status-rs", syscall.SIGUSR2)
if reached == nil {
return ReloadResult{Reached: []int{}, Note: "no bar is running: i3 starts them with the session"}
}
return ReloadResult{Reached: reached, Note: "each bar re-read its configuration"}
}
// Config is a bar's file cut into the part before its blocks and each block's own text.
type Config struct {
Header string
Blocks []string
}
var blockStart = regexp.MustCompile(`(?m)^\[\[block\]\]\s*$`)
func splitConfig(raw string) Config {
idx := blockStart.FindAllStringIndex(raw, -1)
if len(idx) == 0 {
return Config{Header: raw}
}
c := Config{Header: raw[:idx[0][0]]}
for i, at := range idx {
end := len(raw)
if i+1 < len(idx) {
end = idx[i+1][0]
}
c.Blocks = append(c.Blocks, raw[at[0]:end])
}
return c
}
// Block is one block of a bar.
type Block struct {
Index int `json:"index"`
Kind string `json:"block"`
Settings map[string]string `json:"settings"`
Text *string `json:"text,omitempty"`
State string `json:"state,omitempty"`
}
var keyValue = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
// describe reads a block's own top-level settings (not its sub-tables, such as clicks), as written.
func describe(index int, raw string) Block {
b := Block{Index: index, Settings: map[string]string{}}
for _, line := range strings.Split(raw, "\n")[1:] {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
break
}
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if m := keyValue.FindStringSubmatch(line); m != nil {
v := strings.Trim(m[2], `"`)
if m[1] == "block" {
b.Kind = v
} else {
b.Settings[m[1]] = v
}
}
}
return b
}
// BarBlocks is one bar's blocks.
type BarBlocks struct {
Bar string `json:"bar"`
File string `json:"file"`
Blocks []Block `json:"blocks"`
Error string `json:"error,omitempty"`
}
// Blocks describes each bar's blocks, and with values what each shows now.
func Blocks(bar string, values bool) ([]BarBlocks, error) {
which, err := barsOf(bar)
if err != nil {
return nil, err
}
var out []BarBlocks
for _, b := range which {
file := barFile(b)
raw, err := os.ReadFile(file)
if err != nil {
out = append(out, BarBlocks{Bar: b, File: file, Blocks: []Block{}, Error: err.Error()})
continue
}
c := splitConfig(string(raw))
bb := BarBlocks{Bar: b, File: file, Blocks: []Block{}}
for i, text := range c.Blocks {
bb.Blocks = append(bb.Blocks, describe(i, text))
}
if values {
shown, err := runBar(file)
if err != nil {
bb.Error = err.Error()
}
for i := range bb.Blocks {
if w, ok := shown[i]; ok {
text := w.text
bb.Blocks[i].Text, bb.Blocks[i].State = &text, w.state
}
}
}
out = append(out, bb)
}
return out, nil
}
type widgetText struct{ text, state string }
// runBar runs i3status-rs on a file for a moment and answers each block's latest text, by index.
// i3status-rs names each block's widgets by an instance "<index>:…", which is how they are told apart.
func runBar(file string) (map[int]widgetText, error) {
s := findEnvironment() // the session when there is one; blocks that need none run without
r, err := s.run(runFor, "", "i3status-rs", file)
if err != nil && !errors.Is(err, ErrTimedOut) {
return nil, err
}
shown := parseStatus(r.Stdout)
if len(shown) == 0 {
msg := strings.TrimSpace(r.Stderr)
if msg == "" {
msg = "the bar showed nothing within " + runFor.String()
}
return shown, errors.New(msg)
}
return shown, nil
}
// parseStatus reads i3bar's protocol — a header, "[", then one JSON array per update — and answers
// the last complete update's text per block index.
func parseStatus(stream string) map[int]widgetText {
var last []map[string]any
for _, line := range strings.Split(stream, "\n") {
line = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), ","), ",")
if !strings.HasPrefix(line, "[{") && line != "[]" {
continue
}
var update []map[string]any
if json.Unmarshal([]byte(line), &update) == nil {
last = update
}
}
out := map[int]widgetText{}
for _, w := range last {
instance, _ := w["instance"].(string)
head, _, ok := strings.Cut(instance, ":")
if !ok {
continue // a separator
}
i, err := strconv.Atoi(head)
if err != nil {
continue
}
text, _ := w["full_text"].(string)
cur := out[i]
cur.text = strings.TrimSpace(strings.TrimSpace(cur.text) + " " + strings.TrimSpace(text))
if colour, _ := w["color"].(string); strings.EqualFold(colour, "#FF0000FF") {
cur.state = "critical"
}
out[i] = cur
}
return out
}
// RunResult is what i3status_rust_block_run answers.
type RunResult struct {
Bar string `json:"bar"`
Block Block `json:"block"`
Error string `json:"error,omitempty"`
}
// BlockRun runs one block of a bar alone, with the bar's theme and icons.
func BlockRun(bar string, index int) (RunResult, error) {
if _, err := barsOf(bar); err != nil || bar == "" {
return RunResult{}, fmt.Errorf("bar %q is top or bottom", bar)
}
raw, err := os.ReadFile(barFile(bar))
if err != nil {
return RunResult{}, err
}
c := splitConfig(string(raw))
if index >= len(c.Blocks) {
return RunResult{}, fmt.Errorf("the %s bar has %d blocks, numbered from 0", bar, len(c.Blocks))
}
tmp, err := os.CreateTemp("", "i3status-rust-block-*.toml")
if err != nil {
return RunResult{}, err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(c.Header + c.Blocks[index]); err != nil {
tmp.Close()
return RunResult{}, err
}
tmp.Close()
out := RunResult{Bar: bar, Block: describe(index, c.Blocks[index])}
shown, err := runBar(tmp.Name())
if err != nil {
out.Error = err.Error()
}
if w, ok := shown[0]; ok {
text := w.text
out.Block.Text, out.Block.State = &text, w.state
}
return out, nil
}
// Theme is one bar theme.
type Theme struct {
Name string `json:"name"`
File string `json:"file"`
Source string `json:"source"`
}
// ThemesResult is what i3status_rust_themes answers.
type ThemesResult struct {
InUse map[string]string `json:"in_use"`
Themes []Theme `json:"themes"`
}
type themeDir struct{ path, source string }
func themeDirs() []themeDir {
return []themeDir{
{filepath.Join(configDir(), "themes"), "the account's"},
{filepath.Join(operatorHome(), ".local", "share", "i3status-rust", "themes"), "the account's"},
{"/usr/share/i3status-rust/themes", "the distribution's"},
}
}
var themeName = regexp.MustCompile(`(?ms)^\[theme\]\s*$.*?^theme\s*=\s*"([^"]+)"`)
// Themes lists the themes, the first directory winning a name, and the one each bar names.
func Themes(dirs []themeDir) ThemesResult {
out := ThemesResult{InUse: map[string]string{}, Themes: []Theme{}}
for _, b := range bars {
if raw, err := os.ReadFile(barFile(b)); err == nil {
if m := themeName.FindStringSubmatch(string(raw)); m != nil {
out.InUse[b] = m[1]
}
}
}
seen := map[string]bool{}
for _, d := range dirs {
entries, err := os.ReadDir(d.path)
if err != nil {
continue
}
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".toml")
if !ok || seen[name] {
continue
}
seen[name] = true
out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source})
}
}
sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name })
return out
}
@@ -0,0 +1,141 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func source(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "files", name))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestTheModulesBarsSplitIntoTheirBlocksInOrder(t *testing.T) {
c := splitConfig(source(t, "bottom-bar.toml"))
var kinds []string
for i, b := range c.Blocks {
kinds = append(kinds, describe(i, b).Kind)
}
want := []string{"tea_timer", "custom", "cpu", "disk_space", "disk_space", "memory", "docker", "sound", "weather", "notify", "time"}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("%v", kinds)
}
if !strings.Contains(c.Header, `theme = "plain"`) || strings.Contains(c.Header, "[[block]]") {
t.Fatalf("header: %s", c.Header)
}
custom := describe(1, c.Blocks[1])
if custom.Settings["command"] != "~/.local/bin/i3status-updates 10" || custom.Settings["interval"] != "1800" {
t.Fatalf("a block's own settings, not its click's: %+v", custom)
}
if _, leaked := custom.Settings["cmd"]; leaked {
t.Fatal("the click's cmd was read as the block's")
}
top := splitConfig(source(t, "top-bar.toml"))
if len(top.Blocks) != 2 || describe(0, top.Blocks[0]).Kind != "focused_window" {
t.Fatalf("top: %+v", top.Blocks)
}
}
// Two updates of i3bar's protocol, as i3status-rs wrote them for the top bar on 2026-10-04.
const stream = `{"version": 1, "click_events": true}
[
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" up 1d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":"failed to connect to wayland ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":" | "},{"full_text":" up 2d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
`
func TestTheLatestUpdateIsReadPerBlockIndex(t *testing.T) {
got := parseStatus(stream)
if got[1].text != "up 2d" || got[1].state != "" || got[0].text != "failed to connect to wayland" || got[0].state != "critical" || len(got) != 2 {
t.Fatalf("%+v", got)
}
if len(parseStatus("garbage\n[\n")) != 0 {
t.Fatal("no update is no blocks")
}
}
// installBars puts the module's bars where i3status-rs would read them, and a fake i3status-rs.
func installBars(t *testing.T) string {
t.Helper()
root := fakeMachine(t)
dir := filepath.Join(root, "home", ".config", "i3status-rust")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, f := range []string{"top-bar.toml", "bottom-bar.toml"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte(source(t, f)), 0o644); err != nil {
t.Fatal(err)
}
}
bin := fakeBinaries(t, map[string]string{"i3status-rs": `cp "$1" "$LOG.config"
printf '%s\n' '{"version": 1}' '[' '[{"full_text":"first","instance":"0:"},{"full_text":"second","instance":"1:"}],'`})
t.Setenv("LOG", filepath.Join(bin, "log"))
return bin
}
func TestBlocksPairEachBlockWithWhatItShows(t *testing.T) {
installBars(t)
got, err := Blocks("top", true)
if err != nil || len(got) != 1 {
t.Fatalf("%+v, %v", got, err)
}
b := got[0].Blocks
if len(b) != 2 || b[0].Kind != "focused_window" || b[0].Text == nil || *b[0].Text != "first" || *b[1].Text != "second" {
t.Fatalf("%+v", got)
}
if all, _ := Blocks("", false); len(all) != 2 || all[1].Blocks[0].Text != nil {
t.Fatalf("both bars, without values: %+v", all)
}
if _, err := Blocks("side", false); err == nil {
t.Fatal("an unknown bar was accepted")
}
}
func TestOneBlockRunsAloneWithTheBarsThemeAndIcons(t *testing.T) {
bin := installBars(t)
got, err := BlockRun("bottom", 8)
if err != nil || got.Block.Kind != "weather" || got.Block.Text == nil || *got.Block.Text != "first" {
t.Fatalf("%+v, %v", got, err)
}
ran, _ := os.ReadFile(filepath.Join(bin, "log.config"))
if strings.Count(string(ran), "[[block]]") != 1 || !strings.Contains(string(ran), `name = "metno"`) ||
!strings.Contains(string(ran), `icons = "custom-icons"`) {
t.Fatalf("the config it ran:\n%s", ran)
}
if _, err := BlockRun("bottom", 11); err == nil {
t.Fatal("a block past the end was accepted")
}
if _, err := BlockRun("", 0); err == nil {
t.Fatal("no bar was accepted")
}
}
func TestThemesListEachOnceWithTheOnesInUse(t *testing.T) {
installBars(t)
root := t.TempDir()
for _, f := range []string{"mine/plain.toml", "system/plain.toml", "system/nord-dark.toml", "system/README"} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
t.Fatal(err)
}
}
got := Themes([]themeDir{{filepath.Join(root, "mine"), "mine"}, {filepath.Join(root, "system"), "system"}})
if got.InUse["top"] != "plain" || got.InUse["bottom"] != "plain" || len(got.Themes) != 2 || got.Themes[1].Source != "mine" {
t.Fatalf("%+v", got)
}
}
func TestReloadWithNoBarRunningSaysSo(t *testing.T) {
fakeMachine(t)
if r := Reload(); len(r.Reached) != 0 || !strings.Contains(r.Note, "no bar") {
t.Fatalf("%+v", r)
}
}
@@ -0,0 +1,81 @@
// i3status-rust's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the bars' tools, served by
// the node's runtime as the operator account. node-bar has no verbs yet (ADR 0208 §2), so every tool
// here is the module's own.
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)
}
}
var barInput = map[string]any{"type": "string", "enum": bars, "description": "which bar (default: both)"}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "i3status_rust_reload",
Description: "Make the running bars re-read their configuration (i3status-rs restarts in place). " +
"Answers the processes reached.",
Run: func(map[string]any) (any, error) { return Reload(), nil },
},
{
Name: "i3status_rust_blocks",
Description: "What each bar shows: every block in order, its kind and settings, and with values " +
"(the default) what it shows right now, from one run of the bar's configuration.",
Input: map[string]any{
"bar": barInput,
"values": map[string]any{"type": "boolean", "description": "run the bar once to read each block's current text (default true; a few seconds)"},
},
Run: func(args map[string]any) (any, error) {
bar, err := text(args, "bar", false)
if err != nil {
return nil, err
}
values, err := flag(args, "values", true)
if err != nil {
return nil, err
}
return Blocks(bar, values)
},
},
{
Name: "i3status_rust_block_run",
Description: "Run one block of a bar once, alone, and answer what it shows — to see a block that " +
"errors, or what a click would act on.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"bar": map[string]any{"type": "string", "enum": bars},
"index": map[string]any{"type": "integer", "description": "the block's position in the bar, from 0, as i3status_rust_blocks answers it"},
},
"required": []string{"bar", "index"},
},
Run: func(args map[string]any) (any, error) {
bar, err := text(args, "bar", true)
if err != nil {
return nil, err
}
index, err := whole(args, "index", 0, 0, 200)
if err != nil {
return nil, err
}
return BlockRun(bar, index)
},
},
{
Name: "i3status_rust_themes",
Description: "The bar themes on this machine (the distribution's and the account's own) and the " +
"one each bar uses.",
Run: func(map[string]any) (any, error) { return Themes(themeDirs()), nil },
},
}
}
@@ -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,71 @@
package main
import (
"reflect"
"strings"
"testing"
)
// i3status-rust's shape (novox/hq ADR 0208): it claims node-bar, requires the X display on its own
// machine (its bars are i3bar's), owns its two bars and their icons, places the bars as an i3 drop-in,
// and runs the bar watchdog once per session. No block follows one machine's hardware or one person's
// devices.
func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
m := readManifest(t)
if m.Module != "i3status-rust" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) {
t.Fatalf("claims: %+v", 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{"i3status-rust", "pacman-contrib"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
m := readManifest(t)
for id, src := range map[string]string{
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf",
} {
m.sameAsSource(t, id, src)
}
}
func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) {
m := readManifest(t)
bottom := m.resource(t, "bottom-bar")["content"].(string)
for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} {
if strings.Contains(bottom, never) {
t.Errorf("the bottom bar has %s", never)
}
}
}
func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
m := readManifest(t)
bars := m.resource(t, "i3-bars")
if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
t.Fatalf("%v", bars["path"])
}
c := bars["content"].(string)
if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") ||
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
t.Fatalf("%s", c)
}
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" ||
!strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) {
t.Fatalf("%+v", m.Shell)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
@@ -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
}
@@ -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
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.
#
# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not
# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the
# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the
# cause, which is i3bar's.
#
# Started once per session from the session's start, with the session's own pid; it ends when that
# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.
set -uo pipefail
session_pid="${1:-}"
interval="${I3_BAR_WATCHDOG_INTERVAL:-5}"
# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.
confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}"
# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.
cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}"
log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; }
for tool in i3-msg i3bar pgrep; do
command -v "$tool" >/dev/null 2>&1 || {
log "$tool is missing; not watching"
exit 1
}
done
declare -A missed=() fixed=()
bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; }
outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; }
session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; }
while session_alive; do
sleep "$interval"
socket="$(i3 --get-socketpath 2>/dev/null)"
[ -n "$socket" ] && [ -S "$socket" ] || continue
ids="$(bar_ids)"
[ -n "$ids" ] || continue
while read -r id; do
[ -n "$id" ] || continue
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
missed[$id]=0
continue
fi
missed[$id]=$((${missed[$id]:-0} + 1))
[ "${missed[$id]}" -ge "$confirm" ] || continue
now="$(date +%s)"
if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then
continue
fi
log "$id is gone; respawning it. Outputs now: $(outputs)"
nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 &
disown 2>/dev/null || true
fixed[$id]="$now"
sleep 2
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
missed[$id]=0
else
log "$id exited within 2s of respawning; check its status_command"
fi
done <<<"$ids"
done
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for
# the bottom bar's custom block (json = true). Emits one JSON line.
#
# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw
# red error on every transient network failure, which on a roaming laptop is often. This keeps the
# last good result through a failure and shows an error only after `threshold` failures in a row
# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no
# root); AUR packages through paru when it is installed, and none counted when it is not.
set -uo pipefail
threshold="${1:-10}"
icon="update"
state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates"
mkdir -p "$state_dir"
last="$state_dir/last_good.json"
failures="$state_dir/failures"
json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; }
count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; }
official="$(checkupdates 2>/dev/null)"
official_rc=$?
aur="" aur_rc=0
if command -v paru >/dev/null 2>&1; then
aur="$(paru -Qua 2>/dev/null)"
aur_rc=$?
fi
# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.
if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then
o="$(count "$official")"
a="$(count "$aur")"
total=$((o + a))
if [ "$total" -eq 0 ]; then
payload="$(json Good "up to date" "")"
else
# A kernel update wants a reboot: the one worth not putting off.
state=Info
printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning
label="$o + $a = $total updates"
[ "$a" -eq 0 ] && label="$total updates"
[ "$total" -eq 1 ] && label="1 update"
payload="$(json "$state" "$label" "$total")"
fi
printf '%s' "$payload" >"$last"
printf '0' >"$failures"
printf '%s\n' "$payload"
exit 0
fi
n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1))
printf '%s' "$n" >"$failures"
if [ "$n" -ge "$threshold" ]; then
json Critical "update check failing (${n}x)" "!"
echo
elif [ -s "$last" ]; then
cat "$last"
echo
else
json Idle "" ""
echo
fi
+100
View File
@@ -0,0 +1,100 @@
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
# person's devices left out: the battery and the GPU belong to a machine model's hardware module,
# and a headset's address is not the bar's to know. Every block here works the same on any machine.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "tea_timer"
format = "$icon{ $minutes:$seconds|}"
done_cmd = "notify-send 'Timer finished'"
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
# list, in the launcher's menu.
[[block]]
block = "custom"
command = "~/.local/bin/i3status-updates 10"
json = true
interval = 1800
[[block.click]]
button = "left"
cmd = "checkupdates | dmenu -l 20 -p Updates"
[[block]]
block = "cpu"
[[block]]
block = "disk_space"
path = "/"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "disk_space"
path = "/home"
info_type = "used"
alert_unit = "GB"
interval = 20
warning = 850
alert = 920
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
[[block]]
block = "memory"
format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))"
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
[[block]]
block = "docker"
interval = 2
format = "$icon $running/$total"
[[block]]
block = "sound"
[[block.click]]
button = "left"
cmd = "pavucontrol"
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
[[block]]
block = "weather"
format = "$icon $weather_verbose ($location) $temp"
autolocate = true
autolocate_interval = 600
[block.service]
name = "metno"
[[block]]
block = "notify"
driver = "dunst"
format = " $icon {($notification_count.eng(w:1))|}"
[[block]]
block = "time"
interval = 1
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
@@ -0,0 +1,34 @@
# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom
# bar shows the machine; the top bar the focused window and the tray, on the primary output. The
# face is the monospace one every desktop module names.
bar {
font pango:JetBrainsMono Nerd Font 11
position bottom
status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml
tray_output none
colors {
separator #ffffff
background #000000
statusline #ffffff
# The text on an accent-coloured button is dark: light text on the accent was barely
# legible.
focused_workspace #de5200 #de5200 #000000
active_workspace #de5200 #de5200 #000000
inactive_workspace #000000 #000000 #ffffff
urgent_workspace #2f343a #900000 #ffffff
}
}
bar {
font pango:JetBrainsMono Nerd Font 11
position top
status_command i3status-rs ~/.config/i3status-rust/top-bar.toml
workspace_buttons no
tray_output primary
colors {
separator #ffffff
background #000000
statusline #ffffff
}
}
@@ -0,0 +1,135 @@
# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
# Material from NerdFont
# https://www.nerdfonts.com/cheat-sheet
backlight = [
"\ue38d", # nf-weather-moon_new
"\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6
"\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5
"\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4
"\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3
"\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2
"\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1
"\ue3ce", # nf-weather-moon_alt_first_quarter
"\ue3cd", # nf-weather-moon_alt_waxing_crescent_6
"\ue3cc", # nf-weather-moon_alt_waxing_crescent_5
"\ue3cb", # nf-weather-moon_alt_waxing_crescent_4
"\ue3ca", # nf-weather-moon_alt_waxing_crescent_3
"\ue3c9", # nf-weather-moon_alt_waxing_crescent_2
"\ue3c8", # nf-weather-moon_alt_waxing_crescent_1
"\ue39b", # nf-weather-moon_full
]
bat_charging = "\U000f0084" # nf-md-battery_charging
bat_not_available = "\U000f0091" # nf-md-battery_unknown
bat = [
"\U000f007a", # nf-md-battery_10
"\U000f007b", # nf-md-battery_20
"\U000f007c", # nf-md-battery_30
"\U000f007d", # nf-md-battery_40
"\U000f007e", # nf-md-battery_50
"\U000f007f", # nf-md-battery_60
"\U000f0080", # nf-md-battery_70
"\U000f0081", # nf-md-battery_80
"\U000f0082", # nf-md-battery_90
"\U000f0079", # nf-md-battery
]
bell = "\U000f009c" # nf-md-bell_outline
bell-slash = "\U000f009b" # nf-md-bell_off
blackberry = "\uf307" # nf-md-blackberry
bluetooth = "\U000f00af" # nf-md-bluetooth
calendar = "\U000f00ed" # nf-md-calendar
cogs = "\U000f0493" # nf-md-cog
cpu = [
"\U000F0F86", # nf-md-speedometer_slow
"\U000F0F85", # nf-md-speedometer_medium
"\U000F04C5", # nf-md-speedometer
]
cpu_boost_on = "\U000f0521" # nf-md-toggle_switch
cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
disk_drive = "\U000f02ca" # nf-md-harddisk
docker = "\uf308" # nf-linux-docker
github = "\U000f02a4" # nf-md-github
gpu = "\U000f0379" # nf-md-monitor
headphones = "\U000f02cb" # nf-md-headphones
joystick = "\U000f0297" # nf-md-gamepad_variant
keyboard = "\U000f030c" # nf-md-keyboard
mail = "\U000f01ee" # nf-md-email
memory_mem = "\U000f035b" # nf-md-memory
memory_swap = "\U000f02ca" # nf-md-harddisk
mouse = "\U000f037d" # nf-md-mouse
music = "\U000f075a" # nf-md-music
music_next = "\U000f04ad" # nf-md-skip_next
music_pause = "\U000f03e4" # nf-md-pause
music_play = "\U000f040a" # nf-md-play
music_prev = "\U000f04ae" # nf-md-skip_previous
net_bridge = "\U000f04aa" # nf-md-sitemap
net_down = "\U000f01da" # nf-md-download
net_loopback = "\U000f006f" # nf-md-backup_restore
net_modem = "\U000f03f2" # nf-md-phone
net_cellular = [
"\U000F08FD", # nf-md-network_strength_off_outline
"\U000F08FE", # nf-md-network_strength_outline
"\U000F08F4", # nf-md-network_strength_1
"\U000F08F6", # nf-md-network_strength_2
"\U000F08F8", # nf-md-network_strength_3
"\U000F08FA", # nf-md-network_strength_4
]
net_up = "\U000f0552" # nf-md-upload
net_vpn = "\U000f0582" # nf-md-vpn
net_wired = "\U000f0200" # nf-md-ethernet
net_wireless = [
"\U000F092F", # nf-md-wifi_strength_outline
"\U000F091F", # nf-md-wifi_strength_1
"\U000F0922", # nf-md-wifi_strength_2
"\U000F0925", # nf-md-wifi_strength_3
"\U000F0928", # nf-md-wifi_strength_4
]
notification = "\U000f009c" # nf-md-bell_outline
phone = "\U000f03f2" # nf-md-phone
phone_disconnected = "\U000f0658" # nf-md-phone_minus
ping = "\U000f051f" # nf-md-timer_sand
pomodoro = "\ue001" # nf-pom-pomodoro_done
pomodoro_break = "\U000f0176" # nf-md-coffee
pomodoro_paused = "\U000f03e4" # nf-md-pause
pomodoro_started = "\U000f040a" # nf-md-play
pomodoro_stopped = "\U000f04db" # nf-md-stop
resolution = "\U000f0293" # nf-md-fullscreen
tasks = "\U000f05c7" # nf-md-playlist_check
tea = "\U000f0d9e" # nf-md-tea
thermometer = [
"\U000f10c3", # nf-md-thermometer_low
"\U000f050f", # nf-md-thermometer
"\U000f10c2", # nf-md-thermometer_high
]
time = "\U000f0150" # nf-md-clock_outline
toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
toggle_on = "\U000f0521" # nf-md-toggle_switch
unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default?
update = "\U000f03d5" # nf-md-package_up
uptime = "\U000f0153" # nf-md-clock_in
volume_muted = "\U000f075f" # nf-md-volume_mute
volume = [
"\U000f057f", # nf-md-volume_low
"\U000f0580", # nf-md-volume_medium
"\U000f057e", # nf-md-volume_high
]
microphone_muted = "\U000f036d" # nf-md-microphone_off
microphone = [
"\U000f036e", # nf-md-microphone_outline
"\U000f036c", # nf-md-microphone
"\U000f036c", # nf-md-microphone
]
xrandr = "\U000f037a" # nf-md-monitor_multiple
# Weather icons (https://greshake.github.io/i3status-rust/i3status_rs/blocks/weather/index.html)
weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)
weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night)
weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)
weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)
weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)
weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)
weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)
weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)
weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”)
weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)
weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)
+33
View File
@@ -0,0 +1,33 @@
# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
icons_format = "{icon}"
[theme]
theme = "plain"
[theme.overrides]
idle_bg = "#000000"
idle_fg = "#f1f1f1"
info_bg = "#000000"
info_fg = "#f1f1f1"
good_bg = "#000000"
good_fg = "#859900"
warning_bg = "#000000"
warning_fg = "#de5200"
critical_bg = "#000000"
critical_fg = "#ff0000"
separator = " | "
separator_fg = "#ffffff"
separator_bg = "#000000"
[icons]
icons = "custom-icons"
[[block]]
block = "focused_window"
[block.format]
full = " $title.str(max_w:50) |"
short = " $title.str(max_w:100) |"
[[block]]
block = "uptime"
interval = 60
+5
View File
@@ -0,0 +1,5 @@
module i3statusrust
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=
File diff suppressed because one or more lines are too long