package main import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "sync" "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 `. // 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 the one key each such block shows. So it follows 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. // // **The value's age is its own.** The file is dated with the value's `at` when it has one, so the // block hides a value that stopped changing at its source, not only one that stopped arriving here. // FollowEvery is how often the bars are read again and every followed value looked at; Silent is how // long a watch may say nothing before the value is read again — twice the power draw's lifetime. var ( FollowEvery = 30 * time.Second Silent = 60 * time.Second ) // valueCall is how a bar names a value: the command the template renders. A key is a machine's name. var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([a-z0-9][a-z0-9-]*)`) // 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 value to its file: its `text`, dated with its `at` when it has one; no file once the // value is deleted. Each write goes through a temporary file of its own and is renamed into place, so a // watch's write and a reread's never meet halfway. func keep(dir string, w Wanted, op string, value json.RawMessage) error { path := filepath.Join(dir, w.file()) if op == "delete" || op == "purge" || value == nil { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return nil } var v struct { Text string `json:"text"` At string `json:"at"` } if err := json.Unmarshal(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, err := os.CreateTemp(dir, "."+w.file()+".*") if err != nil { return err } defer os.Remove(tmp.Name()) // gone after the rename; left behind only on a failure if _, err := tmp.WriteString(v.Text + "\n"); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if at, err := time.Parse(time.RFC3339, v.At); err == nil { if err := os.Chtimes(tmp.Name(), at, at); err != nil { return err } } return os.Rename(tmp.Name(), path) } // Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the // bundle, a fake in a test. type Bus struct { Watch func(state, key string, handle func(op string, value json.RawMessage) error) error Get func(state, key string) (json.RawMessage, bool, error) } // following is one value followed. type following struct { // last is when its watch last handed anything over, or when it was last started or found expired. last time.Time // gen is which watch is the current one: a watch started again leaves the old one running (the SDK // cannot end it), and what an old one hands over is dropped. gen int // stopped is a value no bar shows any more. stopped bool // dead is a watch found silent while its value went on, said once until a watch speaks again. dead bool // expired is a value its source stopped giving: the key is gone and its watch has nothing to say. expired bool } // Follower follows the values the bars name. type Follower struct { Dir string Bus Bus Now func() time.Time Say func(string) mu sync.Mutex of map[Wanted]*following } // Round reads the bars once and brings what is followed into line with them: // - a value a bar names and nothing follows is watched; a watch the runtime refuses — a read not // granted yet — is said and tried again next round; // - a value whose watch has said nothing for Silent is read once. A key that is gone has expired at // its source (its lifetime ends without an event), and is left until the watch speaks or another // Silent has passed. A key still there means the watch missed it: the value is kept, the watch is // started again, and that is said once — not every round — until a watch speaks again; // - a value no bar names any more is no longer kept: its file goes, and what its watch hands over is // dropped. The SDK has no way to end a watch; one that ends with the bundle is the price. func (f *Follower) Round(files []string) { f.mu.Lock() if f.of == nil { f.of = map[Wanted]*following{} } f.mu.Unlock() wanted := map[Wanted]bool{} for _, w := range wantedIn(files) { wanted[w] = true f.mu.Lock() fw, known := f.of[w] var stopped, silent bool if known { stopped = fw.stopped silent = f.Now().Sub(fw.last) > Silent } f.mu.Unlock() switch { case !known: f.start(w) case stopped: f.mu.Lock() fw.stopped = false f.mu.Unlock() f.start(w) case silent: f.silent(w) } } f.mu.Lock() var gone []Wanted for w, fw := range f.of { if !wanted[w] && !fw.stopped { fw.stopped = true fw.gen++ gone = append(gone, w) } } f.mu.Unlock() for _, w := range gone { if err := keep(f.Dir, w, "delete", nil); err != nil { f.Say(err.Error()) } f.Say(fmt.Sprintf("no bar shows %s %s any more; it is no longer kept", w.State, w.Key)) } } // start watches a value, as a new generation. Its entry is there before the watch begins, because the // SDK hands every current value over before Watch returns, and a value handed to nobody is lost. func (f *Follower) start(w Wanted) { f.mu.Lock() fw, existed := f.of[w] if !existed { fw = &following{} f.of[w] = fw } fw.gen++ gen := fw.gen fw.last = f.Now() f.mu.Unlock() err := f.Bus.Watch(w.State, w.Key, func(op string, value json.RawMessage) error { f.mu.Lock() current := f.of[w] == fw && fw.gen == gen && !fw.stopped if current { fw.last, fw.dead, fw.expired = f.Now(), false, false } f.mu.Unlock() if !current { return nil } return keep(f.Dir, w, op, value) }) if err != nil { f.mu.Lock() if !existed { delete(f.of, w) } else { fw.gen++ // the refused watch is no generation; the next round tries again } f.mu.Unlock() f.Say(fmt.Sprintf("cannot watch %s %s: %v; trying again in %s", w.State, w.Key, err, FollowEvery)) } } // silent looks once at a value whose watch has said nothing for Silent. func (f *Follower) silent(w Wanted) { value, found, err := f.Bus.Get(w.State, w.Key) f.mu.Lock() fw := f.of[w] fw.last = f.Now() wasDead, wasExpired := fw.dead, fw.expired f.mu.Unlock() switch { case err != nil: f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err)) case !found: // Expired at its source: nothing to show, and nothing wrong with the watch. if err := keep(f.Dir, w, "delete", nil); err != nil { f.Say(err.Error()) } f.mu.Lock() fw.expired = true f.mu.Unlock() if !wasExpired { f.Say(fmt.Sprintf("%s %s has expired at its source; showing nothing until it is given again", w.State, w.Key)) } default: if err := keep(f.Dir, w, "put", value); err != nil { f.Say(err.Error()) } if !wasDead { f.Say(fmt.Sprintf("the watch of %s %s said nothing for %s while its value went on; watching it again", w.State, w.Key, Silent)) } f.mu.Lock() fw.dead = true f.mu.Unlock() f.start(w) } } // FollowValues runs a Round every FollowEvery for as long as the bundle runs, so a block a push adds // is followed without a restart. func FollowValues() { f := &Follower{Dir: valuesDir(), Now: time.Now, Say: func(s string) { fmt.Fprintln(os.Stderr, "i3status-rust:", s) }, Bus: Bus{ Watch: func(state, key string, handle func(string, json.RawMessage) error) error { return stdio.State(state).Watch(key, func(c stdio.StateChange) error { return handle(c.Op, c.Value) }) }, Get: func(state, key string) (json.RawMessage, bool, error) { e, err := stdio.State(state).Get(key) if err != nil || e == nil { return nil, false, err } return e.Value, true, nil }, }} for { files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml")) f.Round(files) time.Sleep(FollowEvery) } }