i3status-rust: the bars as a module, claiming node-bar (hq ADR 0208)
Owns both bars and their icons, the bar blocks as an i3 drop-in; the battery, GPU and headset blocks leave (hardware and a person's devices), the weather needs no key; the update count and the bar watchdog become module code, the watchdog started once per session. Go tools reload, blocks, block-run and themes.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# i3status-rust
|
||||
|
||||
The bars as a module (novox/hq ADR 0208, research 026/05).
|
||||
|
||||
- Installs `i3status-rust`, and `pacman-contrib` for `checkupdates`, which the update block uses.
|
||||
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own
|
||||
machine: its bars are i3bar's.
|
||||
- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set
|
||||
(`icons/custom-icons.toml`).
|
||||
- Places the two `bar { }` blocks as its own i3 drop-in, `~/.config/i3/config.d/60-i3status-rust.conf`:
|
||||
- the bottom bar shows the machine;
|
||||
- the top bar shows the focused window and the tray, on the primary output.
|
||||
- Places two programs in `~/.local/bin`:
|
||||
- `i3status-updates`, the pending-updates block;
|
||||
- `i3bar-watchdog`, which brings back a bar that died.
|
||||
- Starts the watchdog once per session, from the session's start (the `xinitrc` slot `normal`). It ends
|
||||
with the session's own process.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `i3status_rust_reload` | the running bars re-read their files (i3status-rs restarts in place) |
|
||||
| `i3status_rust_blocks` | each bar's blocks in order, kind and settings, and what each shows now (one run of the bar) |
|
||||
| `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors |
|
||||
| `i3status_rust_themes` | the themes on the machine and the one each bar uses |
|
||||
|
||||
## The battery, and what else was left out
|
||||
|
||||
A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208
|
||||
§1), so the bottom bar here has none:
|
||||
|
||||
- **the battery,** which only the laptop has;
|
||||
- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop;
|
||||
- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's.
|
||||
|
||||
i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only,
|
||||
so a hardware module has no way into this file yet. Two ways forward, for the operator to choose:
|
||||
|
||||
1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's
|
||||
hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4.
|
||||
2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for
|
||||
example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware.
|
||||
That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar.
|
||||
|
||||
Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned.
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
- **The weather needs no key.** The found block used a weather service with an API key written in
|
||||
plain text in the file, and a fixed city. It now uses the Norwegian Meteorological Institute, which
|
||||
needs no key, located automatically.
|
||||
- **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It
|
||||
counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the
|
||||
launcher's menu, instead of a theme from a cloned theme repository.
|
||||
- **The faces** are JetBrains Mono Nerd Font, not Hack.
|
||||
- **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the
|
||||
other. It runs on both, once per session, and ends with the session.
|
||||
- The docker block's click, which ran a program installed on neither machine, is gone.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- `~/.config/i3status-rust/scripts/` (a mouse battery script for one device).
|
||||
- `~/scripts/pkg-updates` and `~/scripts/i3-bar-watchdog`, replaced here.
|
||||
- The user unit `~/.config/systemd/user/i3-bar-watchdog.service` and its enablement link.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
1. **Revoke the weather API key** that was in the found `bottom-bar.toml`. It sat in plain text on
|
||||
both workstations. The first push keeps the found file once and then writes the module's, which
|
||||
has no key.
|
||||
2. Before the first push, on the laptop: `systemctl --user disable --now i3-bar-watchdog.service`.
|
||||
Otherwise two watchdogs run. Then delete the unit file, `~/scripts/i3-bar-watchdog` and
|
||||
`~/scripts/pkg-updates`.
|
||||
3. Until the `i3` module carries the main configuration, the found `~/.config/i3/config` still has its
|
||||
own two `bar { }` blocks, and i3 shows four bars. The `i3` module's configuration has none.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
|
||||
`mctl` reads them as unknown.
|
||||
- The battery block waits for one of the two ways above.
|
||||
- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it
|
||||
runs per session and ends with the session, as a session-start line already does.
|
||||
- `pacman-contrib` is declared here. A future `pacman` module that wants `checkupdates` or `paccache`
|
||||
takes it over, since a package is declared once per node.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var bars = []string{"top", "bottom"}
|
||||
|
||||
// A bar runs this long when a tool reads its values: long enough for every block's first update.
|
||||
const runFor = 4 * time.Second
|
||||
|
||||
func configDir() string { return filepath.Join(operatorHome(), ".config", "i3status-rust") }
|
||||
|
||||
func barFile(bar string) string { return filepath.Join(configDir(), bar+"-bar.toml") }
|
||||
|
||||
func barsOf(bar string) ([]string, error) {
|
||||
if bar == "" {
|
||||
return bars, nil
|
||||
}
|
||||
for _, b := range bars {
|
||||
if b == bar {
|
||||
return []string{bar}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("bar %q is top or bottom", bar)
|
||||
}
|
||||
|
||||
// ReloadResult is what i3status_rust_reload answers.
|
||||
type ReloadResult struct {
|
||||
Reached []int `json:"reached"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Reload restarts every running i3status-rs in place, which re-reads its file (SIGUSR2).
|
||||
func Reload() ReloadResult {
|
||||
reached := signalAll("i3status-rs", syscall.SIGUSR2)
|
||||
if reached == nil {
|
||||
return ReloadResult{Reached: []int{}, Note: "no bar is running: i3 starts them with the session"}
|
||||
}
|
||||
return ReloadResult{Reached: reached, Note: "each bar re-read its configuration"}
|
||||
}
|
||||
|
||||
// Config is a bar's file cut into the part before its blocks and each block's own text.
|
||||
type Config struct {
|
||||
Header string
|
||||
Blocks []string
|
||||
}
|
||||
|
||||
var blockStart = regexp.MustCompile(`(?m)^\[\[block\]\]\s*$`)
|
||||
|
||||
func splitConfig(raw string) Config {
|
||||
idx := blockStart.FindAllStringIndex(raw, -1)
|
||||
if len(idx) == 0 {
|
||||
return Config{Header: raw}
|
||||
}
|
||||
c := Config{Header: raw[:idx[0][0]]}
|
||||
for i, at := range idx {
|
||||
end := len(raw)
|
||||
if i+1 < len(idx) {
|
||||
end = idx[i+1][0]
|
||||
}
|
||||
c.Blocks = append(c.Blocks, raw[at[0]:end])
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Block is one block of a bar.
|
||||
type Block struct {
|
||||
Index int `json:"index"`
|
||||
Kind string `json:"block"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
var keyValue = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
|
||||
|
||||
// describe reads a block's own top-level settings (not its sub-tables, such as clicks), as written.
|
||||
func describe(index int, raw string) Block {
|
||||
b := Block{Index: index, Settings: map[string]string{}}
|
||||
for _, line := range strings.Split(raw, "\n")[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") {
|
||||
break
|
||||
}
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if m := keyValue.FindStringSubmatch(line); m != nil {
|
||||
v := strings.Trim(m[2], `"`)
|
||||
if m[1] == "block" {
|
||||
b.Kind = v
|
||||
} else {
|
||||
b.Settings[m[1]] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BarBlocks is one bar's blocks.
|
||||
type BarBlocks struct {
|
||||
Bar string `json:"bar"`
|
||||
File string `json:"file"`
|
||||
Blocks []Block `json:"blocks"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Blocks describes each bar's blocks, and with values what each shows now.
|
||||
func Blocks(bar string, values bool) ([]BarBlocks, error) {
|
||||
which, err := barsOf(bar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []BarBlocks
|
||||
for _, b := range which {
|
||||
file := barFile(b)
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
out = append(out, BarBlocks{Bar: b, File: file, Blocks: []Block{}, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
c := splitConfig(string(raw))
|
||||
bb := BarBlocks{Bar: b, File: file, Blocks: []Block{}}
|
||||
for i, text := range c.Blocks {
|
||||
bb.Blocks = append(bb.Blocks, describe(i, text))
|
||||
}
|
||||
if values {
|
||||
shown, err := runBar(file)
|
||||
if err != nil {
|
||||
bb.Error = err.Error()
|
||||
}
|
||||
for i := range bb.Blocks {
|
||||
if w, ok := shown[i]; ok {
|
||||
text := w.text
|
||||
bb.Blocks[i].Text, bb.Blocks[i].State = &text, w.state
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, bb)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type widgetText struct{ text, state string }
|
||||
|
||||
// runBar runs i3status-rs on a file for a moment and answers each block's latest text, by index.
|
||||
// i3status-rs names each block's widgets by an instance "<index>:…", which is how they are told apart.
|
||||
func runBar(file string) (map[int]widgetText, error) {
|
||||
s := findEnvironment() // the session when there is one; blocks that need none run without
|
||||
r, err := s.run(runFor, "", "i3status-rs", file)
|
||||
if err != nil && !errors.Is(err, ErrTimedOut) {
|
||||
return nil, err
|
||||
}
|
||||
shown := parseStatus(r.Stdout)
|
||||
if len(shown) == 0 {
|
||||
msg := strings.TrimSpace(r.Stderr)
|
||||
if msg == "" {
|
||||
msg = "the bar showed nothing within " + runFor.String()
|
||||
}
|
||||
return shown, errors.New(msg)
|
||||
}
|
||||
return shown, nil
|
||||
}
|
||||
|
||||
// parseStatus reads i3bar's protocol — a header, "[", then one JSON array per update — and answers
|
||||
// the last complete update's text per block index.
|
||||
func parseStatus(stream string) map[int]widgetText {
|
||||
var last []map[string]any
|
||||
for _, line := range strings.Split(stream, "\n") {
|
||||
line = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), ","), ",")
|
||||
if !strings.HasPrefix(line, "[{") && line != "[]" {
|
||||
continue
|
||||
}
|
||||
var update []map[string]any
|
||||
if json.Unmarshal([]byte(line), &update) == nil {
|
||||
last = update
|
||||
}
|
||||
}
|
||||
out := map[int]widgetText{}
|
||||
for _, w := range last {
|
||||
instance, _ := w["instance"].(string)
|
||||
head, _, ok := strings.Cut(instance, ":")
|
||||
if !ok {
|
||||
continue // a separator
|
||||
}
|
||||
i, err := strconv.Atoi(head)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text, _ := w["full_text"].(string)
|
||||
cur := out[i]
|
||||
cur.text = strings.TrimSpace(strings.TrimSpace(cur.text) + " " + strings.TrimSpace(text))
|
||||
if colour, _ := w["color"].(string); strings.EqualFold(colour, "#FF0000FF") {
|
||||
cur.state = "critical"
|
||||
}
|
||||
out[i] = cur
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunResult is what i3status_rust_block_run answers.
|
||||
type RunResult struct {
|
||||
Bar string `json:"bar"`
|
||||
Block Block `json:"block"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// BlockRun runs one block of a bar alone, with the bar's theme and icons.
|
||||
func BlockRun(bar string, index int) (RunResult, error) {
|
||||
if _, err := barsOf(bar); err != nil || bar == "" {
|
||||
return RunResult{}, fmt.Errorf("bar %q is top or bottom", bar)
|
||||
}
|
||||
raw, err := os.ReadFile(barFile(bar))
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
c := splitConfig(string(raw))
|
||||
if index >= len(c.Blocks) {
|
||||
return RunResult{}, fmt.Errorf("the %s bar has %d blocks, numbered from 0", bar, len(c.Blocks))
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "i3status-rust-block-*.toml")
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.WriteString(c.Header + c.Blocks[index]); err != nil {
|
||||
tmp.Close()
|
||||
return RunResult{}, err
|
||||
}
|
||||
tmp.Close()
|
||||
out := RunResult{Bar: bar, Block: describe(index, c.Blocks[index])}
|
||||
shown, err := runBar(tmp.Name())
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
}
|
||||
if w, ok := shown[0]; ok {
|
||||
text := w.text
|
||||
out.Block.Text, out.Block.State = &text, w.state
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Theme is one bar theme.
|
||||
type Theme struct {
|
||||
Name string `json:"name"`
|
||||
File string `json:"file"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ThemesResult is what i3status_rust_themes answers.
|
||||
type ThemesResult struct {
|
||||
InUse map[string]string `json:"in_use"`
|
||||
Themes []Theme `json:"themes"`
|
||||
}
|
||||
|
||||
type themeDir struct{ path, source string }
|
||||
|
||||
func themeDirs() []themeDir {
|
||||
return []themeDir{
|
||||
{filepath.Join(configDir(), "themes"), "the account's"},
|
||||
{filepath.Join(operatorHome(), ".local", "share", "i3status-rust", "themes"), "the account's"},
|
||||
{"/usr/share/i3status-rust/themes", "the distribution's"},
|
||||
}
|
||||
}
|
||||
|
||||
var themeName = regexp.MustCompile(`(?ms)^\[theme\]\s*$.*?^theme\s*=\s*"([^"]+)"`)
|
||||
|
||||
// Themes lists the themes, the first directory winning a name, and the one each bar names.
|
||||
func Themes(dirs []themeDir) ThemesResult {
|
||||
out := ThemesResult{InUse: map[string]string{}, Themes: []Theme{}}
|
||||
for _, b := range bars {
|
||||
if raw, err := os.ReadFile(barFile(b)); err == nil {
|
||||
if m := themeName.FindStringSubmatch(string(raw)); m != nil {
|
||||
out.InUse[b] = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
entries, err := os.ReadDir(d.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
name, ok := strings.CutSuffix(e.Name(), ".toml")
|
||||
if !ok || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source})
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func source(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "files", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestTheModulesBarsSplitIntoTheirBlocksInOrder(t *testing.T) {
|
||||
c := splitConfig(source(t, "bottom-bar.toml"))
|
||||
var kinds []string
|
||||
for i, b := range c.Blocks {
|
||||
kinds = append(kinds, describe(i, b).Kind)
|
||||
}
|
||||
want := []string{"tea_timer", "custom", "cpu", "disk_space", "disk_space", "memory", "docker", "sound", "weather", "notify", "time"}
|
||||
if !reflect.DeepEqual(kinds, want) {
|
||||
t.Fatalf("%v", kinds)
|
||||
}
|
||||
if !strings.Contains(c.Header, `theme = "plain"`) || strings.Contains(c.Header, "[[block]]") {
|
||||
t.Fatalf("header: %s", c.Header)
|
||||
}
|
||||
custom := describe(1, c.Blocks[1])
|
||||
if custom.Settings["command"] != "~/.local/bin/i3status-updates 10" || custom.Settings["interval"] != "1800" {
|
||||
t.Fatalf("a block's own settings, not its click's: %+v", custom)
|
||||
}
|
||||
if _, leaked := custom.Settings["cmd"]; leaked {
|
||||
t.Fatal("the click's cmd was read as the block's")
|
||||
}
|
||||
top := splitConfig(source(t, "top-bar.toml"))
|
||||
if len(top.Blocks) != 2 || describe(0, top.Blocks[0]).Kind != "focused_window" {
|
||||
t.Fatalf("top: %+v", top.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// Two updates of i3bar's protocol, as i3status-rs wrote them for the top bar on 2026-10-04.
|
||||
const stream = `{"version": 1, "click_events": true}
|
||||
[
|
||||
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" up 1d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
|
||||
[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":"failed to connect to wayland ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":" | "},{"full_text":" up 2d ","color":"#F1F1F1FF","name":"0","instance":"1:"}],
|
||||
`
|
||||
|
||||
func TestTheLatestUpdateIsReadPerBlockIndex(t *testing.T) {
|
||||
got := parseStatus(stream)
|
||||
if got[1].text != "up 2d" || got[1].state != "" || got[0].text != "failed to connect to wayland" || got[0].state != "critical" || len(got) != 2 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if len(parseStatus("garbage\n[\n")) != 0 {
|
||||
t.Fatal("no update is no blocks")
|
||||
}
|
||||
}
|
||||
|
||||
// installBars puts the module's bars where i3status-rs would read them, and a fake i3status-rs.
|
||||
func installBars(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := fakeMachine(t)
|
||||
dir := filepath.Join(root, "home", ".config", "i3status-rust")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"top-bar.toml", "bottom-bar.toml"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte(source(t, f)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bin := fakeBinaries(t, map[string]string{"i3status-rs": `cp "$1" "$LOG.config"
|
||||
printf '%s\n' '{"version": 1}' '[' '[{"full_text":"first","instance":"0:"},{"full_text":"second","instance":"1:"}],'`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestBlocksPairEachBlockWithWhatItShows(t *testing.T) {
|
||||
installBars(t)
|
||||
got, err := Blocks("top", true)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
b := got[0].Blocks
|
||||
if len(b) != 2 || b[0].Kind != "focused_window" || b[0].Text == nil || *b[0].Text != "first" || *b[1].Text != "second" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if all, _ := Blocks("", false); len(all) != 2 || all[1].Blocks[0].Text != nil {
|
||||
t.Fatalf("both bars, without values: %+v", all)
|
||||
}
|
||||
if _, err := Blocks("side", false); err == nil {
|
||||
t.Fatal("an unknown bar was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneBlockRunsAloneWithTheBarsThemeAndIcons(t *testing.T) {
|
||||
bin := installBars(t)
|
||||
got, err := BlockRun("bottom", 8)
|
||||
if err != nil || got.Block.Kind != "weather" || got.Block.Text == nil || *got.Block.Text != "first" {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
ran, _ := os.ReadFile(filepath.Join(bin, "log.config"))
|
||||
if strings.Count(string(ran), "[[block]]") != 1 || !strings.Contains(string(ran), `name = "metno"`) ||
|
||||
!strings.Contains(string(ran), `icons = "custom-icons"`) {
|
||||
t.Fatalf("the config it ran:\n%s", ran)
|
||||
}
|
||||
if _, err := BlockRun("bottom", 11); err == nil {
|
||||
t.Fatal("a block past the end was accepted")
|
||||
}
|
||||
if _, err := BlockRun("", 0); err == nil {
|
||||
t.Fatal("no bar was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemesListEachOnceWithTheOnesInUse(t *testing.T) {
|
||||
installBars(t)
|
||||
root := t.TempDir()
|
||||
for _, f := range []string{"mine/plain.toml", "system/plain.toml", "system/nord-dark.toml", "system/README"} {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got := Themes([]themeDir{{filepath.Join(root, "mine"), "mine"}, {filepath.Join(root, "system"), "system"}})
|
||||
if got.InUse["top"] != "plain" || got.InUse["bottom"] != "plain" || len(got.Themes) != 2 || got.Themes[1].Source != "mine" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadWithNoBarRunningSaysSo(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if r := Reload(); len(r.Reached) != 0 || !strings.Contains(r.Note, "no bar") {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// i3status-rust's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the bars' tools, served by
|
||||
// the node's runtime as the operator account. node-bar has no verbs yet (ADR 0208 §2), so every tool
|
||||
// here is the module's own.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var barInput = map[string]any{"type": "string", "enum": bars, "description": "which bar (default: both)"}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "i3status_rust_reload",
|
||||
Description: "Make the running bars re-read their configuration (i3status-rs restarts in place). " +
|
||||
"Answers the processes reached.",
|
||||
Run: func(map[string]any) (any, error) { return Reload(), nil },
|
||||
},
|
||||
{
|
||||
Name: "i3status_rust_blocks",
|
||||
Description: "What each bar shows: every block in order, its kind and settings, and with values " +
|
||||
"(the default) what it shows right now, from one run of the bar's configuration.",
|
||||
Input: map[string]any{
|
||||
"bar": barInput,
|
||||
"values": map[string]any{"type": "boolean", "description": "run the bar once to read each block's current text (default true; a few seconds)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
bar, err := text(args, "bar", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := flag(args, "values", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Blocks(bar, values)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "i3status_rust_block_run",
|
||||
Description: "Run one block of a bar once, alone, and answer what it shows — to see a block that " +
|
||||
"errors, or what a click would act on.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"bar": map[string]any{"type": "string", "enum": bars},
|
||||
"index": map[string]any{"type": "integer", "description": "the block's position in the bar, from 0, as i3status_rust_blocks answers it"},
|
||||
},
|
||||
"required": []string{"bar", "index"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
bar, err := text(args, "bar", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index, err := whole(args, "index", 0, 0, 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BlockRun(bar, index)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "i3status_rust_themes",
|
||||
Description: "The bar themes on this machine (the distribution's and the account's own) and the " +
|
||||
"one each bar uses.",
|
||||
Run: func(map[string]any) (any, error) { return Themes(themeDirs()), nil },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// i3status-rust's shape (novox/hq ADR 0208): it claims node-bar, requires the X display on its own
|
||||
// machine (its bars are i3bar's), owns its two bars and their icons, places the bars as an i3 drop-in,
|
||||
// and runs the bar watchdog once per session. No block follows one machine's hardware or one person's
|
||||
// devices.
|
||||
|
||||
func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "i3status-rust" || m.Seats != nil {
|
||||
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("requires: %v", m.Requires)
|
||||
}
|
||||
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust", "pacman-contrib"}) || absent != nil {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for id, src := range map[string]string{
|
||||
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
|
||||
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf",
|
||||
} {
|
||||
m.sameAsSource(t, id, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
bottom := m.resource(t, "bottom-bar")["content"].(string)
|
||||
for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} {
|
||||
if strings.Contains(bottom, never) {
|
||||
t.Errorf("the bottom bar has %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
bars := m.resource(t, "i3-bars")
|
||||
if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" {
|
||||
t.Fatalf("%v", bars["path"])
|
||||
}
|
||||
c := bars["content"].(string)
|
||||
if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") ||
|
||||
!strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") {
|
||||
t.Fatalf("%s", c)
|
||||
}
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" ||
|
||||
!strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.
|
||||
#
|
||||
# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not
|
||||
# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the
|
||||
# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the
|
||||
# cause, which is i3bar's.
|
||||
#
|
||||
# Started once per session from the session's start, with the session's own pid; it ends when that
|
||||
# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.
|
||||
set -uo pipefail
|
||||
|
||||
session_pid="${1:-}"
|
||||
interval="${I3_BAR_WATCHDOG_INTERVAL:-5}"
|
||||
# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.
|
||||
confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}"
|
||||
# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.
|
||||
cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}"
|
||||
|
||||
log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; }
|
||||
|
||||
for tool in i3-msg i3bar pgrep; do
|
||||
command -v "$tool" >/dev/null 2>&1 || {
|
||||
log "$tool is missing; not watching"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
declare -A missed=() fixed=()
|
||||
|
||||
bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; }
|
||||
outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; }
|
||||
session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; }
|
||||
|
||||
while session_alive; do
|
||||
sleep "$interval"
|
||||
socket="$(i3 --get-socketpath 2>/dev/null)"
|
||||
[ -n "$socket" ] && [ -S "$socket" ] || continue
|
||||
ids="$(bar_ids)"
|
||||
[ -n "$ids" ] || continue
|
||||
while read -r id; do
|
||||
[ -n "$id" ] || continue
|
||||
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
|
||||
missed[$id]=0
|
||||
continue
|
||||
fi
|
||||
missed[$id]=$((${missed[$id]:-0} + 1))
|
||||
[ "${missed[$id]}" -ge "$confirm" ] || continue
|
||||
now="$(date +%s)"
|
||||
if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then
|
||||
continue
|
||||
fi
|
||||
log "$id is gone; respawning it. Outputs now: $(outputs)"
|
||||
nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
fixed[$id]="$now"
|
||||
sleep 2
|
||||
if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then
|
||||
missed[$id]=0
|
||||
else
|
||||
log "$id exited within 2s of respawning; check its status_command"
|
||||
fi
|
||||
done <<<"$ids"
|
||||
done
|
||||
+63
@@ -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
|
||||
@@ -0,0 +1,100 @@
|
||||
# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every
|
||||
# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one
|
||||
# person's devices left out: the battery and the GPU belong to a machine model's hardware module,
|
||||
# and a headset's address is not the bar's to know. Every block here works the same on any machine.
|
||||
icons_format = "{icon}"
|
||||
|
||||
[theme]
|
||||
theme = "plain"
|
||||
[theme.overrides]
|
||||
idle_bg = "#000000"
|
||||
idle_fg = "#f1f1f1"
|
||||
info_bg = "#000000"
|
||||
info_fg = "#f1f1f1"
|
||||
good_bg = "#000000"
|
||||
good_fg = "#859900"
|
||||
warning_bg = "#000000"
|
||||
warning_fg = "#de5200"
|
||||
critical_bg = "#000000"
|
||||
critical_fg = "#ff0000"
|
||||
separator = " | "
|
||||
separator_fg = "#ffffff"
|
||||
separator_bg = "#000000"
|
||||
|
||||
[icons]
|
||||
icons = "custom-icons"
|
||||
|
||||
[[block]]
|
||||
block = "tea_timer"
|
||||
format = "$icon{ $minutes:$seconds|}"
|
||||
done_cmd = "notify-send 'Timer finished'"
|
||||
|
||||
# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good
|
||||
# count through a network gap and shows an error only after ten failed checks in a row. Click: the
|
||||
# list, in the launcher's menu.
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = "~/.local/bin/i3status-updates 10"
|
||||
json = true
|
||||
interval = 1800
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "checkupdates | dmenu -l 20 -p Updates"
|
||||
|
||||
[[block]]
|
||||
block = "cpu"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "disk_space"
|
||||
path = "/home"
|
||||
info_type = "used"
|
||||
alert_unit = "GB"
|
||||
interval = 20
|
||||
warning = 850
|
||||
alert = 920
|
||||
format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "memory"
|
||||
format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))"
|
||||
format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))"
|
||||
|
||||
[[block]]
|
||||
block = "docker"
|
||||
interval = 2
|
||||
format = "$icon $running/$total"
|
||||
|
||||
[[block]]
|
||||
block = "sound"
|
||||
[[block.click]]
|
||||
button = "left"
|
||||
cmd = "pavucontrol"
|
||||
|
||||
# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.
|
||||
[[block]]
|
||||
block = "weather"
|
||||
format = "$icon $weather_verbose ($location) $temp"
|
||||
autolocate = true
|
||||
autolocate_interval = 600
|
||||
[block.service]
|
||||
name = "metno"
|
||||
|
||||
[[block]]
|
||||
block = "notify"
|
||||
driver = "dunst"
|
||||
format = " $icon {($notification_count.eng(w:1))|}"
|
||||
|
||||
[[block]]
|
||||
block = "time"
|
||||
interval = 1
|
||||
format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')"
|
||||
@@ -0,0 +1,34 @@
|
||||
# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.
|
||||
# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom
|
||||
# bar shows the machine; the top bar the focused window and the tray, on the primary output. The
|
||||
# face is the monospace one every desktop module names.
|
||||
bar {
|
||||
font pango:JetBrainsMono Nerd Font 11
|
||||
position bottom
|
||||
status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml
|
||||
tray_output none
|
||||
colors {
|
||||
separator #ffffff
|
||||
background #000000
|
||||
statusline #ffffff
|
||||
# The text on an accent-coloured button is dark: light text on the accent was barely
|
||||
# legible.
|
||||
focused_workspace #de5200 #de5200 #000000
|
||||
active_workspace #de5200 #de5200 #000000
|
||||
inactive_workspace #000000 #000000 #ffffff
|
||||
urgent_workspace #2f343a #900000 #ffffff
|
||||
}
|
||||
}
|
||||
|
||||
bar {
|
||||
font pango:JetBrainsMono Nerd Font 11
|
||||
position top
|
||||
status_command i3status-rs ~/.config/i3status-rust/top-bar.toml
|
||||
workspace_buttons no
|
||||
tray_output primary
|
||||
colors {
|
||||
separator #ffffff
|
||||
background #000000
|
||||
statusline #ffffff
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.
|
||||
# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.
|
||||
# Material from NerdFont
|
||||
# https://www.nerdfonts.com/cheat-sheet
|
||||
backlight = [
|
||||
"\ue38d", # nf-weather-moon_new
|
||||
"\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6
|
||||
"\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5
|
||||
"\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4
|
||||
"\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3
|
||||
"\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2
|
||||
"\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1
|
||||
"\ue3ce", # nf-weather-moon_alt_first_quarter
|
||||
"\ue3cd", # nf-weather-moon_alt_waxing_crescent_6
|
||||
"\ue3cc", # nf-weather-moon_alt_waxing_crescent_5
|
||||
"\ue3cb", # nf-weather-moon_alt_waxing_crescent_4
|
||||
"\ue3ca", # nf-weather-moon_alt_waxing_crescent_3
|
||||
"\ue3c9", # nf-weather-moon_alt_waxing_crescent_2
|
||||
"\ue3c8", # nf-weather-moon_alt_waxing_crescent_1
|
||||
"\ue39b", # nf-weather-moon_full
|
||||
]
|
||||
bat_charging = "\U000f0084" # nf-md-battery_charging
|
||||
bat_not_available = "\U000f0091" # nf-md-battery_unknown
|
||||
bat = [
|
||||
"\U000f007a", # nf-md-battery_10
|
||||
"\U000f007b", # nf-md-battery_20
|
||||
"\U000f007c", # nf-md-battery_30
|
||||
"\U000f007d", # nf-md-battery_40
|
||||
"\U000f007e", # nf-md-battery_50
|
||||
"\U000f007f", # nf-md-battery_60
|
||||
"\U000f0080", # nf-md-battery_70
|
||||
"\U000f0081", # nf-md-battery_80
|
||||
"\U000f0082", # nf-md-battery_90
|
||||
"\U000f0079", # nf-md-battery
|
||||
]
|
||||
bell = "\U000f009c" # nf-md-bell_outline
|
||||
bell-slash = "\U000f009b" # nf-md-bell_off
|
||||
blackberry = "\uf307" # nf-md-blackberry
|
||||
bluetooth = "\U000f00af" # nf-md-bluetooth
|
||||
calendar = "\U000f00ed" # nf-md-calendar
|
||||
cogs = "\U000f0493" # nf-md-cog
|
||||
cpu = [
|
||||
"\U000F0F86", # nf-md-speedometer_slow
|
||||
"\U000F0F85", # nf-md-speedometer_medium
|
||||
"\U000F04C5", # nf-md-speedometer
|
||||
]
|
||||
cpu_boost_on = "\U000f0521" # nf-md-toggle_switch
|
||||
cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
|
||||
disk_drive = "\U000f02ca" # nf-md-harddisk
|
||||
docker = "\uf308" # nf-linux-docker
|
||||
github = "\U000f02a4" # nf-md-github
|
||||
gpu = "\U000f0379" # nf-md-monitor
|
||||
headphones = "\U000f02cb" # nf-md-headphones
|
||||
joystick = "\U000f0297" # nf-md-gamepad_variant
|
||||
keyboard = "\U000f030c" # nf-md-keyboard
|
||||
mail = "\U000f01ee" # nf-md-email
|
||||
memory_mem = "\U000f035b" # nf-md-memory
|
||||
memory_swap = "\U000f02ca" # nf-md-harddisk
|
||||
mouse = "\U000f037d" # nf-md-mouse
|
||||
music = "\U000f075a" # nf-md-music
|
||||
music_next = "\U000f04ad" # nf-md-skip_next
|
||||
music_pause = "\U000f03e4" # nf-md-pause
|
||||
music_play = "\U000f040a" # nf-md-play
|
||||
music_prev = "\U000f04ae" # nf-md-skip_previous
|
||||
net_bridge = "\U000f04aa" # nf-md-sitemap
|
||||
net_down = "\U000f01da" # nf-md-download
|
||||
net_loopback = "\U000f006f" # nf-md-backup_restore
|
||||
net_modem = "\U000f03f2" # nf-md-phone
|
||||
net_cellular = [
|
||||
"\U000F08FD", # nf-md-network_strength_off_outline
|
||||
"\U000F08FE", # nf-md-network_strength_outline
|
||||
"\U000F08F4", # nf-md-network_strength_1
|
||||
"\U000F08F6", # nf-md-network_strength_2
|
||||
"\U000F08F8", # nf-md-network_strength_3
|
||||
"\U000F08FA", # nf-md-network_strength_4
|
||||
]
|
||||
net_up = "\U000f0552" # nf-md-upload
|
||||
net_vpn = "\U000f0582" # nf-md-vpn
|
||||
net_wired = "\U000f0200" # nf-md-ethernet
|
||||
net_wireless = [
|
||||
"\U000F092F", # nf-md-wifi_strength_outline
|
||||
"\U000F091F", # nf-md-wifi_strength_1
|
||||
"\U000F0922", # nf-md-wifi_strength_2
|
||||
"\U000F0925", # nf-md-wifi_strength_3
|
||||
"\U000F0928", # nf-md-wifi_strength_4
|
||||
]
|
||||
notification = "\U000f009c" # nf-md-bell_outline
|
||||
phone = "\U000f03f2" # nf-md-phone
|
||||
phone_disconnected = "\U000f0658" # nf-md-phone_minus
|
||||
ping = "\U000f051f" # nf-md-timer_sand
|
||||
pomodoro = "\ue001" # nf-pom-pomodoro_done
|
||||
pomodoro_break = "\U000f0176" # nf-md-coffee
|
||||
pomodoro_paused = "\U000f03e4" # nf-md-pause
|
||||
pomodoro_started = "\U000f040a" # nf-md-play
|
||||
pomodoro_stopped = "\U000f04db" # nf-md-stop
|
||||
resolution = "\U000f0293" # nf-md-fullscreen
|
||||
tasks = "\U000f05c7" # nf-md-playlist_check
|
||||
tea = "\U000f0d9e" # nf-md-tea
|
||||
thermometer = [
|
||||
"\U000f10c3", # nf-md-thermometer_low
|
||||
"\U000f050f", # nf-md-thermometer
|
||||
"\U000f10c2", # nf-md-thermometer_high
|
||||
]
|
||||
time = "\U000f0150" # nf-md-clock_outline
|
||||
toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline
|
||||
toggle_on = "\U000f0521" # nf-md-toggle_switch
|
||||
unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default?
|
||||
update = "\U000f03d5" # nf-md-package_up
|
||||
uptime = "\U000f0153" # nf-md-clock_in
|
||||
volume_muted = "\U000f075f" # nf-md-volume_mute
|
||||
volume = [
|
||||
"\U000f057f", # nf-md-volume_low
|
||||
"\U000f0580", # nf-md-volume_medium
|
||||
"\U000f057e", # nf-md-volume_high
|
||||
]
|
||||
microphone_muted = "\U000f036d" # nf-md-microphone_off
|
||||
microphone = [
|
||||
"\U000f036e", # nf-md-microphone_outline
|
||||
"\U000f036c", # nf-md-microphone
|
||||
"\U000f036c", # nf-md-microphone
|
||||
]
|
||||
xrandr = "\U000f037a" # nf-md-monitor_multiple
|
||||
|
||||
# Weather icons (https://greshake.github.io/i3status-rust/i3status_rs/blocks/weather/index.html)
|
||||
weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)
|
||||
weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night)
|
||||
weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)
|
||||
weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)
|
||||
weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)
|
||||
weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)
|
||||
weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)
|
||||
weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)
|
||||
weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”)
|
||||
weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)
|
||||
weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module i3statusrust
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user