Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)
Modules talk over the bus. The power module's bundle puts each fresh reading of its own sampler in its state draw, keyed by the machine; the bar's block shows that state, which the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses a module naming another module's path under /run or /var/lib.
This commit is contained in:
@@ -41,6 +41,14 @@ contributes it to `node-bar` as data that says what it shows, and this module re
|
||||
- **Where it applies:** a contribution may name a capability the machine must report. The `power`
|
||||
module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's
|
||||
bar gets both and a desktop only the power draw.
|
||||
- **A value from the bus:** a block that `shows: state` names another module's state, such as
|
||||
`power.draw`. The mesh grants this module the read, because a module on its machine offers it; the
|
||||
manifest names no other module. This module's bundle watches it and leaves the value's text in the
|
||||
account's runtime directory (`i3status-rust/values/`), and the block runs `~/.local/bin/i3status-value`,
|
||||
which prints it, or nothing when it is more than 30 s old, so the block hides. No file of another
|
||||
module is read: modules talk over the bus.
|
||||
- **The battery** is i3status-rust's own battery block, which reads the kernel's power-supply class
|
||||
itself: the bar reading the machine, not another module.
|
||||
|
||||
Still left out:
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
@@ -33,7 +34,7 @@ func blockTemplate(t *testing.T) *template.Template {
|
||||
|
||||
func render(t *testing.T, tmpl *template.Template, piece string) string {
|
||||
t.Helper()
|
||||
view := map[string]any{"module": "power"}
|
||||
view := map[string]any{"module": "power", "machine": "laptop"}
|
||||
if err := json.Unmarshal([]byte(piece), &view); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,3 +97,24 @@ func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) {
|
||||
t.Fatal("the memory block counts the cache as used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributedStateIsACustomBlockReadingThisMachinesValue(t *testing.T) {
|
||||
tmpl := blockTemplate(t)
|
||||
got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`)
|
||||
want := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n"
|
||||
if got != want {
|
||||
t.Fatalf("%q, want %q", got, want)
|
||||
}
|
||||
got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw","key":"desk"}}`)
|
||||
if !strings.Contains(got, "i3status-value power.draw desk") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
// And the bundle finds what it rendered.
|
||||
f := filepath.Join(t.TempDir(), "bottom-bar.toml")
|
||||
if err := os.WriteFile(f, []byte(got), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "desk"}) {
|
||||
t.Fatalf("%v", w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// The values other modules keep on the bus for their blocks (novox/hq ADR 0260).
|
||||
go FollowValues()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.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",
|
||||
"value": "files/bin/i3status-value",
|
||||
} {
|
||||
m.sameAsSource(t, id, src)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The values other modules keep on the bus for a block they contributed (novox/hq ADR 0260).
|
||||
//
|
||||
// A block that `shows` a state is rendered as a custom block running `i3status-value <state> <key>`.
|
||||
// i3status-rs cannot reach the bus, and this bundle can: the node's runtime launched it, and the mesh
|
||||
// granted this module a read of every state the modules on its machine offer the bar. So it watches each
|
||||
// state its bars name and leaves the value's text in this module's own directory, where the block's
|
||||
// command reads it. No file of another module is read, and nothing but the bus passes between them.
|
||||
|
||||
// valueCall is how a bar names a value: the command the template renders.
|
||||
var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([^\s"]+)`)
|
||||
|
||||
// Wanted is one state and key a bar shows.
|
||||
type Wanted struct{ State, Key string }
|
||||
|
||||
func (w Wanted) file() string { return w.State + "@" + w.Key }
|
||||
|
||||
// wantedIn is every value the bars' files name, each once, in order.
|
||||
func wantedIn(files []string) []Wanted {
|
||||
seen := map[Wanted]bool{}
|
||||
for _, f := range files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range valueCall.FindAllStringSubmatch(string(raw), -1) {
|
||||
seen[Wanted{State: m[1], Key: m[2]}] = true
|
||||
}
|
||||
}
|
||||
out := make([]Wanted, 0, len(seen))
|
||||
for w := range seen {
|
||||
out = append(out, w)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].file() < out[j].file() })
|
||||
return out
|
||||
}
|
||||
|
||||
// valuesDir is where the values are left: the account's runtime directory, gone at logout.
|
||||
func valuesDir() string {
|
||||
return filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "i3status-rust", "values")
|
||||
}
|
||||
|
||||
// keep writes one change of a value to its file: the value's `text`, or no file once it is deleted.
|
||||
func keep(dir string, w Wanted, c stdio.StateChange) error {
|
||||
path := filepath.Join(dir, w.file())
|
||||
if c.Op == "delete" || c.Op == "purge" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var v struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(c.Value, &v); err != nil {
|
||||
return fmt.Errorf("%s's value for %s has no text: %w", w.State, w.Key, err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(v.Text+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Watcher is a function that watches one key of one state; the SDK's in the bundle, a fake in a test.
|
||||
type Watcher func(state, key string, handle func(stdio.StateChange) error) error
|
||||
|
||||
// followValues watches every value the bars name, starting the ones not yet watched each time it is
|
||||
// called; one the runtime refuses — a read not granted yet — is tried again the next time.
|
||||
func followValues(files []string, dir string, watching map[Wanted]bool, watch Watcher) []string {
|
||||
var said []string
|
||||
for _, w := range wantedIn(files) {
|
||||
if watching[w] {
|
||||
continue
|
||||
}
|
||||
w := w
|
||||
err := watch(w.State, w.Key, func(c stdio.StateChange) error { return keep(dir, w, c) })
|
||||
if err != nil {
|
||||
said = append(said, fmt.Sprintf("cannot watch %s %s: %v", w.State, w.Key, err))
|
||||
continue
|
||||
}
|
||||
watching[w] = true
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// FollowValues runs followValues every half minute for as long as the bundle runs, so a block a push
|
||||
// adds is followed without a restart.
|
||||
func FollowValues() {
|
||||
watching := map[Wanted]bool{}
|
||||
watch := func(state, key string, handle func(stdio.StateChange) error) error {
|
||||
return stdio.State(state).Watch(key, handle)
|
||||
}
|
||||
for {
|
||||
files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml"))
|
||||
for _, s := range followValues(files, valuesDir(), watching, watch) {
|
||||
fmt.Fprintln(os.Stderr, "i3status-rust:", s)
|
||||
}
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this
|
||||
// module's own bundle and directory, and an old one is not shown as if it were now.
|
||||
|
||||
func TestAValueIsKeptAsItsTextAndGoneWhenDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := Wanted{"power.draw", "laptop"}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`{"text":"18.2 W machine","watts":18.2}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "power.draw@laptop"))
|
||||
if err != nil || string(b) != "18.2 W machine\n" {
|
||||
t.Fatalf("%q %v", b, err)
|
||||
}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "delete"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) {
|
||||
t.Fatal("a deleted value is still shown")
|
||||
}
|
||||
if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`"no text"`)}); err == nil {
|
||||
t.Fatal("a value with no text was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedWatchIsTriedAgainAndAWatchedOneIsNot(t *testing.T) {
|
||||
bar := filepath.Join(t.TempDir(), "bottom-bar.toml")
|
||||
if err := os.WriteFile(bar, []byte(`command = "~/.local/bin/i3status-value power.draw laptop"`+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
watching := map[Wanted]bool{}
|
||||
calls := 0
|
||||
refuse := func(string, string, func(stdio.StateChange) error) error { calls++; return errors.New("not granted") }
|
||||
if said := followValues([]string{bar}, t.TempDir(), watching, refuse); len(said) != 1 || !strings.Contains(said[0], "not granted") {
|
||||
t.Fatalf("%v", said)
|
||||
}
|
||||
accept := func(state, key string, _ func(stdio.StateChange) error) error {
|
||||
calls++
|
||||
if state != "power.draw" || key != "laptop" {
|
||||
t.Fatalf("watched %s %s", state, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
followValues([]string{bar}, t.TempDir(), watching, accept)
|
||||
followValues([]string{bar}, t.TempDir(), watching, accept)
|
||||
if calls != 2 {
|
||||
t.Fatalf("%d watches; once refused, once taken, then left alone", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBlocksCommandShowsOnlyAFreshValue(t *testing.T) {
|
||||
runtime := t.TempDir()
|
||||
dir := filepath.Join(runtime, "i3status-rust", "values")
|
||||
run := func() string {
|
||||
cmd := exec.Command("sh", "../../files/bin/i3status-value", "power.draw", "laptop")
|
||||
cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+runtime)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
if got := run(); got != "" {
|
||||
t.Fatalf("no value: %q", got)
|
||||
}
|
||||
if err := keep(dir, Wanted{"power.draw", "laptop"}, stdio.StateChange{Op: "put", Value: []byte(`{"text":"8.0 W CPU"}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "8.0 W CPU" {
|
||||
t.Fatalf("fresh: %q", got)
|
||||
}
|
||||
old := time.Now().Add(-31 * time.Second)
|
||||
if err := os.Chtimes(filepath.Join(dir, "power.draw@laptop"), old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(); got != "" {
|
||||
t.Fatalf("31 s old: %q", got)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# i3status-value <state> <key> (module i3status-rust, novox/hq ADR 0260): one value another module keeps
|
||||
# on the bus, for a block it contributed.
|
||||
#
|
||||
# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves
|
||||
# the value's text in this module's own directory; this prints it. A value not refreshed for thirty
|
||||
# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an
|
||||
# old number as if it were now.
|
||||
dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values"
|
||||
f="$dir/$1@$2"
|
||||
if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then
|
||||
head -n 1 "$f"
|
||||
fi
|
||||
@@ -1,5 +1,5 @@
|
||||
{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller
|
||||
renders one piece at a time: its fields (bar, place, order, shows, options) and module. */ -}}
|
||||
renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}
|
||||
{{- if eq .shows "battery" -}}
|
||||
[[block]]
|
||||
block = "battery"
|
||||
@@ -16,5 +16,11 @@ missing_format = ""
|
||||
block = "custom"
|
||||
command = {{quote .options.command}}
|
||||
interval = {{.options.interval}}
|
||||
{{- else if eq .shows "state" -}}
|
||||
[[block]]
|
||||
block = "custom"
|
||||
command = {{quote (printf "~/.local/bin/i3status-value %s %s" .options.state (or .options.key .machine))}}
|
||||
interval = 5
|
||||
hide_when_empty = true
|
||||
{{- end}}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "node-bar",
|
||||
"scope": "node",
|
||||
"renders": {
|
||||
"block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options) and module. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- end}}\n\n"
|
||||
"block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- else if eq .shows \"state\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote (printf \"~/.local/bin/i3status-value %s %s\" .options.state (or .options.key .machine))}}\ninterval = 5\nhide_when_empty = true\n{{- end}}\n\n"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -88,6 +88,14 @@
|
||||
"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": "value",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.local/bin/i3status-value",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755",
|
||||
"content": "#!/bin/sh\n# i3status-value <state> <key> (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user