From 838e1c26166d263ed3de2e2e7492fe8b1f0818ed Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 13:04:52 +0200 Subject: [PATCH] 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. --- modules/i3status-rust/README.md | 86 ++++ .../cmd/i3status-rust-tools/args.go | 97 ++++ .../cmd/i3status-rust-tools/bar.go | 306 +++++++++++++ .../cmd/i3status-rust-tools/bar_test.go | 141 ++++++ .../cmd/i3status-rust-tools/main.go | 81 ++++ .../manifest_helpers_test.go | 175 ++++++++ .../cmd/i3status-rust-tools/manifest_test.go | 71 +++ .../cmd/i3status-rust-tools/session.go | 423 ++++++++++++++++++ .../cmd/i3status-rust-tools/session_test.go | 174 +++++++ .../i3status-rust/files/bin/i3bar-watchdog | 64 +++ .../i3status-rust/files/bin/i3status-updates | 63 +++ modules/i3status-rust/files/bottom-bar.toml | 100 +++++ .../files/i3/60-i3status-rust.conf | 34 ++ .../files/icons/custom-icons.toml | 135 ++++++ modules/i3status-rust/files/top-bar.toml | 33 ++ modules/i3status-rust/go.mod | 5 + modules/i3status-rust/go.sum | 2 + modules/i3status-rust/module.json | 118 +++++ 18 files changed, 2108 insertions(+) create mode 100644 modules/i3status-rust/README.md create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/args.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/bar.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/main.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/session.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/session_test.go create mode 100755 modules/i3status-rust/files/bin/i3bar-watchdog create mode 100755 modules/i3status-rust/files/bin/i3status-updates create mode 100644 modules/i3status-rust/files/bottom-bar.toml create mode 100644 modules/i3status-rust/files/i3/60-i3status-rust.conf create mode 100644 modules/i3status-rust/files/icons/custom-icons.toml create mode 100644 modules/i3status-rust/files/top-bar.toml create mode 100644 modules/i3status-rust/go.mod create mode 100644 modules/i3status-rust/go.sum create mode 100644 modules/i3status-rust/module.json diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md new file mode 100644 index 0000000..6759d3c --- /dev/null +++ b/modules/i3status-rust/README.md @@ -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. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/args.go b/modules/i3status-rust/cmd/i3status-rust-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/args.go @@ -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 +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/bar.go b/modules/i3status-rust/cmd/i3status-rust-tools/bar.go new file mode 100644 index 0000000..7914d41 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/bar.go @@ -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 ":…", 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 +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go new file mode 100644 index 0000000..fb1da5d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go @@ -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) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/main.go b/modules/i3status-rust/cmd/i3status-rust-tools/main.go new file mode 100644 index 0000000..90918c1 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/main.go @@ -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 }, + }, + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -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 ., 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) + } + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go new file mode 100644 index 0000000..0b4dde1 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -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) +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/session.go b/modules/i3status-rust/cmd/i3status-rust-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/session.go @@ -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 +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/session_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/session_test.go @@ -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 +} diff --git a/modules/i3status-rust/files/bin/i3bar-watchdog b/modules/i3status-rust/files/bin/i3bar-watchdog new file mode 100755 index 0000000..63b2b02 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3bar-watchdog @@ -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 diff --git a/modules/i3status-rust/files/bin/i3status-updates b/modules/i3status-rust/files/bin/i3status-updates new file mode 100755 index 0000000..1a70cf9 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3status-updates @@ -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 diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml new file mode 100644 index 0000000..8098093 --- /dev/null +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -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')" diff --git a/modules/i3status-rust/files/i3/60-i3status-rust.conf b/modules/i3status-rust/files/i3/60-i3status-rust.conf new file mode 100644 index 0000000..20acbb1 --- /dev/null +++ b/modules/i3status-rust/files/i3/60-i3status-rust.conf @@ -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 + } +} diff --git a/modules/i3status-rust/files/icons/custom-icons.toml b/modules/i3status-rust/files/icons/custom-icons.toml new file mode 100644 index 0000000..685c9d3 --- /dev/null +++ b/modules/i3status-rust/files/icons/custom-icons.toml @@ -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) diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml new file mode 100644 index 0000000..1166036 --- /dev/null +++ b/modules/i3status-rust/files/top-bar.toml @@ -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 diff --git a/modules/i3status-rust/go.mod b/modules/i3status-rust/go.mod new file mode 100644 index 0000000..c13caf3 --- /dev/null +++ b/modules/i3status-rust/go.mod @@ -0,0 +1,5 @@ +module i3statusrust + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/i3status-rust/go.sum b/modules/i3status-rust/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/i3status-rust/go.sum @@ -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= diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json new file mode 100644 index 0000000..4510d6a --- /dev/null +++ b/modules/i3status-rust/module.json @@ -0,0 +1,118 @@ +{ + "module": "i3status-rust", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-bar", + "scope": "node" + } + ], + "tools": [ + "i3status_rust_reload", + "i3status_rust_blocks", + "i3status_rust_block_run", + "i3status_rust_themes" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this\n# brings it back. Once per session, ending with the session's own process ($$).\n\"$HOME/.local/bin/i3bar-watchdog\" \"$$\" &\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3status-rust" + }, + { + "id": "update-check", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "icons-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust/icons", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "top-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n" + }, + { + "id": "bottom-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: the battery and the GPU belong to a machine model's hardware module,\n# and a headset's address is not the bar's to know. Every block here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\nformat = \"$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + }, + { + "id": "icons", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\n# Material from NerdFont\n# https://www.nerdfonts.com/cheat-sheet\nbacklight = [\n \"\\ue38d\", # nf-weather-moon_new\n \"\\ue3d4\", # nf-weather-moon_alt_waxing_gibbous_6\n \"\\ue3d3\", # nf-weather-moon_alt_waxing_gibbous_5\n \"\\ue3d2\", # nf-weather-moon_alt_waxing_gibbous_4\n \"\\ue3d1\", # nf-weather-moon_alt_waxing_gibbous_3\n \"\\ue3d0\", # nf-weather-moon_alt_waxing_gibbous_2\n \"\\ue3cf\", # nf-weather-moon_alt_waxing_gibbous_1\n \"\\ue3ce\", # nf-weather-moon_alt_first_quarter\n \"\\ue3cd\", # nf-weather-moon_alt_waxing_crescent_6\n \"\\ue3cc\", # nf-weather-moon_alt_waxing_crescent_5\n \"\\ue3cb\", # nf-weather-moon_alt_waxing_crescent_4\n \"\\ue3ca\", # nf-weather-moon_alt_waxing_crescent_3\n \"\\ue3c9\", # nf-weather-moon_alt_waxing_crescent_2\n \"\\ue3c8\", # nf-weather-moon_alt_waxing_crescent_1\n \"\\ue39b\", # nf-weather-moon_full\n]\nbat_charging = \"\\U000f0084\" # nf-md-battery_charging\nbat_not_available = \"\\U000f0091\" # nf-md-battery_unknown\nbat = [\n \"\\U000f007a\", # nf-md-battery_10\n \"\\U000f007b\", # nf-md-battery_20\n \"\\U000f007c\", # nf-md-battery_30\n \"\\U000f007d\", # nf-md-battery_40\n \"\\U000f007e\", # nf-md-battery_50\n \"\\U000f007f\", # nf-md-battery_60\n \"\\U000f0080\", # nf-md-battery_70\n \"\\U000f0081\", # nf-md-battery_80\n \"\\U000f0082\", # nf-md-battery_90\n \"\\U000f0079\", # nf-md-battery\n]\nbell = \"\\U000f009c\" # nf-md-bell_outline\nbell-slash = \"\\U000f009b\" # nf-md-bell_off\nblackberry = \"\\uf307\" # nf-md-blackberry\nbluetooth = \"\\U000f00af\" # nf-md-bluetooth\ncalendar = \"\\U000f00ed\" # nf-md-calendar\ncogs = \"\\U000f0493\" # nf-md-cog\ncpu = [\n\t\"\\U000F0F86\", # nf-md-speedometer_slow\n\t\"\\U000F0F85\", # nf-md-speedometer_medium\n\t\"\\U000F04C5\", # nf-md-speedometer\n]\ncpu_boost_on = \"\\U000f0521\" # nf-md-toggle_switch\ncpu_boost_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ndisk_drive = \"\\U000f02ca\" # nf-md-harddisk\ndocker = \"\\uf308\" # nf-linux-docker\ngithub = \"\\U000f02a4\" # nf-md-github\ngpu = \"\\U000f0379\" # nf-md-monitor\nheadphones = \"\\U000f02cb\" # nf-md-headphones\njoystick = \"\\U000f0297\" # nf-md-gamepad_variant\nkeyboard = \"\\U000f030c\" # nf-md-keyboard\nmail = \"\\U000f01ee\" # nf-md-email\nmemory_mem = \"\\U000f035b\" # nf-md-memory\nmemory_swap = \"\\U000f02ca\" # nf-md-harddisk\nmouse = \"\\U000f037d\" # nf-md-mouse\nmusic = \"\\U000f075a\" # nf-md-music\nmusic_next = \"\\U000f04ad\" # nf-md-skip_next\nmusic_pause = \"\\U000f03e4\" # nf-md-pause\nmusic_play = \"\\U000f040a\" # nf-md-play\nmusic_prev = \"\\U000f04ae\" # nf-md-skip_previous\nnet_bridge = \"\\U000f04aa\" # nf-md-sitemap\nnet_down = \"\\U000f01da\" # nf-md-download\nnet_loopback = \"\\U000f006f\" # nf-md-backup_restore\nnet_modem = \"\\U000f03f2\" # nf-md-phone\nnet_cellular = [\n \"\\U000F08FD\", # nf-md-network_strength_off_outline\n \"\\U000F08FE\", # nf-md-network_strength_outline\n \"\\U000F08F4\", # nf-md-network_strength_1\n \"\\U000F08F6\", # nf-md-network_strength_2\n \"\\U000F08F8\", # nf-md-network_strength_3\n \"\\U000F08FA\", # nf-md-network_strength_4\n]\nnet_up = \"\\U000f0552\" # nf-md-upload\nnet_vpn = \"\\U000f0582\" # nf-md-vpn\nnet_wired = \"\\U000f0200\" # nf-md-ethernet\nnet_wireless = [\n\t\"\\U000F092F\", # nf-md-wifi_strength_outline\n\t\"\\U000F091F\", # nf-md-wifi_strength_1\n\t\"\\U000F0922\", # nf-md-wifi_strength_2\n\t\"\\U000F0925\", # nf-md-wifi_strength_3\n\t\"\\U000F0928\", # nf-md-wifi_strength_4\n]\nnotification = \"\\U000f009c\" # nf-md-bell_outline\nphone = \"\\U000f03f2\" # nf-md-phone\nphone_disconnected = \"\\U000f0658\" # nf-md-phone_minus\nping = \"\\U000f051f\" # nf-md-timer_sand\npomodoro = \"\\ue001\" # nf-pom-pomodoro_done\npomodoro_break = \"\\U000f0176\" # nf-md-coffee\npomodoro_paused = \"\\U000f03e4\" # nf-md-pause\npomodoro_started = \"\\U000f040a\" # nf-md-play\npomodoro_stopped = \"\\U000f04db\" # nf-md-stop\nresolution = \"\\U000f0293\" # nf-md-fullscreen\ntasks = \"\\U000f05c7\" # nf-md-playlist_check\ntea = \"\\U000f0d9e\" # nf-md-tea\nthermometer = [\n \"\\U000f10c3\", # nf-md-thermometer_low\n \"\\U000f050f\", # nf-md-thermometer\n \"\\U000f10c2\", # nf-md-thermometer_high\n]\ntime = \"\\U000f0150\" # nf-md-clock_outline\ntoggle_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ntoggle_on = \"\\U000f0521\" # nf-md-toggle_switch\nunknown = \"\\U000f0186\" # nf-md-comment_question_outline | TODO: Make default?\nupdate = \"\\U000f03d5\" # nf-md-package_up\nuptime = \"\\U000f0153\" # nf-md-clock_in\nvolume_muted = \"\\U000f075f\" # nf-md-volume_mute\nvolume = [\n \"\\U000f057f\", # nf-md-volume_low\n \"\\U000f0580\", # nf-md-volume_medium\n \"\\U000f057e\", # nf-md-volume_high\n]\nmicrophone_muted = \"\\U000f036d\" # nf-md-microphone_off\nmicrophone = [\n\t\"\\U000f036e\", # nf-md-microphone_outline\n \"\\U000f036c\", # nf-md-microphone\n \"\\U000f036c\", # nf-md-microphone\n]\nxrandr = \"\\U000f037a\" # nf-md-monitor_multiple\n\n# Weather icons (https://greshake.github.io/i3status-rust/i3status_rs/blocks/weather/index.html)\nweather_sun = \"\\ue30d\" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)\nweather_moon = \"\\ue32b\" # nf-weather-night_clear (when weather is reported as “Clear” at night)\nweather_clouds = \"\\ue312\" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)\nweather_clouds_night = \"\\ue37e\" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)\nweather_fog = \"\\ue313\" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)\nweather_fog_night = \"\\ue346\" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)\nweather_rain = \"\\ue318\" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)\nweather_rain_night = \"\\ue325\" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)\nweather_snow = \"\\ue31a\" # nf-weather-snow (when weather is reported as “Snow”)\nweather_thunder = \"\\ue31d\" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)\nweather_thunder_night = \"\\ue32a\" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)\n" + }, + { + "id": "updates", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-updates", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for\n# the bottom bar's custom block (json = true). Emits one JSON line.\n#\n# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw\n# red error on every transient network failure, which on a roaming laptop is often. This keeps the\n# last good result through a failure and shows an error only after `threshold` failures in a row\n# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no\n# root); AUR packages through paru when it is installed, and none counted when it is not.\nset -uo pipefail\n\nthreshold=\"${1:-10}\"\nicon=\"update\"\nstate_dir=\"${XDG_RUNTIME_DIR:-/tmp}/i3status-updates\"\nmkdir -p \"$state_dir\"\nlast=\"$state_dir/last_good.json\"\nfailures=\"$state_dir/failures\"\n\njson() { printf '{\"icon\":\"%s\",\"state\":\"%s\",\"text\":\"%s\",\"short_text\":\"%s\"}' \"$icon\" \"$1\" \"$2\" \"$3\"; }\ncount() { [ -n \"$1\" ] && printf '%s\\n' \"$1\" | grep -c . || echo 0; }\n\nofficial=\"$(checkupdates 2>/dev/null)\"\nofficial_rc=$?\naur=\"\" aur_rc=0\nif command -v paru >/dev/null 2>&1; then\n\taur=\"$(paru -Qua 2>/dev/null)\"\n\taur_rc=$?\nfi\n\n# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.\nif { [ \"$official_rc\" -eq 0 ] || [ \"$official_rc\" -eq 2 ]; } && [ \"$aur_rc\" -eq 0 ]; then\n\to=\"$(count \"$official\")\"\n\ta=\"$(count \"$aur\")\"\n\ttotal=$((o + a))\n\tif [ \"$total\" -eq 0 ]; then\n\t\tpayload=\"$(json Good \"up to date\" \"\")\"\n\telse\n\t\t# A kernel update wants a reboot: the one worth not putting off.\n\t\tstate=Info\n\t\tprintf '%s\\n' \"$official\" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning\n\t\tlabel=\"$o + $a = $total updates\"\n\t\t[ \"$a\" -eq 0 ] && label=\"$total updates\"\n\t\t[ \"$total\" -eq 1 ] && label=\"1 update\"\n\t\tpayload=\"$(json \"$state\" \"$label\" \"$total\")\"\n\tfi\n\tprintf '%s' \"$payload\" >\"$last\"\n\tprintf '0' >\"$failures\"\n\tprintf '%s\\n' \"$payload\"\n\texit 0\nfi\n\nn=$(($(cat \"$failures\" 2>/dev/null || echo 0) + 1))\nprintf '%s' \"$n\" >\"$failures\"\nif [ \"$n\" -ge \"$threshold\" ]; then\n\tjson Critical \"update check failing (${n}x)\" \"!\"\n\techo\nelif [ -s \"$last\" ]; then\n\tcat \"$last\"\n\techo\nelse\n\tjson Idle \"\" \"\"\n\techo\nfi\n" + }, + { + "id": "watchdog", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3bar-watchdog", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" + }, + { + "id": "i3-bars", + "type": "file", + "path": "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3status-rust-tools", + "binary": "i3status-rust-tools", + "loads": [ + "i3status-rust-tools" + ] + } + ] + } +}