mesh/merge-gate pass: builds dunst, messenger → g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/notifications-after-review ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
Review found that any process of the operator's account can choose a dunst action, so a click could release or stop a delivery in the operator's name; that tokens lived for ever; that a warning's answers died in ten seconds; and that a restarted notifier left dead buttons (hq ADR 0258).
186 lines
6.3 KiB
Go
186 lines
6.3 KiB
Go
package main
|
|
|
|
// Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a
|
|
// token and a label. dunst offers them on the notification (a middle click, or its context menu, which
|
|
// is the node-launcher's menu); the one the operator chooses is emitted as this module's event
|
|
// `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's
|
|
// event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier
|
|
// seat's.
|
|
//
|
|
// notify-send waits for the answer (`--wait`) in this process: the id is answered at once, and the
|
|
// answer, if any, comes later. An answer is never invented: a notification closed, expired or replaced
|
|
// emits nothing, and a wait ends after AnswerWait. A restart of this bundle ends the waits it held,
|
|
// which the README says; the notification stays shown and its answers do nothing then.
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// Choice is one action a notification offers.
|
|
type Choice struct {
|
|
Token string `json:"token"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
const (
|
|
// MostChoices is how many actions one notification offers.
|
|
MostChoices = 4
|
|
// AnswerWait is how long an answer is waited for.
|
|
AnswerWait = 7 * 24 * time.Hour
|
|
// ChosenEvent is the event an answer is said as; StartedEvent says this bundle started, so the answers
|
|
// an earlier one waited for are lost and the operator channel shows them again (novox/hq ADR 0258).
|
|
ChosenEvent = "action-chosen"
|
|
StartedEvent = "started"
|
|
)
|
|
|
|
// sayStarted says, once this bundle serves, that it started on this machine; retried while the runtime
|
|
// cannot take it yet, and said in the log when it never could.
|
|
func sayStarted() {
|
|
body := map[string]any{"machine": os.Getenv("MESH_NODE")}
|
|
var err error
|
|
for wait := time.Second; wait <= time.Minute; wait *= 2 {
|
|
time.Sleep(wait)
|
|
if err = emit(StartedEvent, body); err == nil {
|
|
return
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[dunst] could not say it started (answers waited for before are lost until the "+
|
|
"operator channel shows them again): %v\n", err)
|
|
}
|
|
|
|
var tokenShape = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
|
|
|
// emit says an event as this module; a seam a test replaces.
|
|
var emit = func(event string, body any) error { return stdio.Emit(event, body) }
|
|
|
|
// choicesOf reads `actions`: a list of {token, label}.
|
|
func choicesOf(args map[string]any) ([]Choice, error) {
|
|
v, present := args["actions"]
|
|
if !present || v == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := v.([]any)
|
|
if !ok {
|
|
return nil, errors.New("actions is a list of {token, label}")
|
|
}
|
|
if len(list) > MostChoices {
|
|
return nil, fmt.Errorf("at most %d actions", MostChoices)
|
|
}
|
|
var out []Choice
|
|
seen := map[string]bool{}
|
|
for _, it := range list {
|
|
o, ok := it.(map[string]any)
|
|
if !ok {
|
|
return nil, errors.New("actions is a list of {token, label}")
|
|
}
|
|
tok, _ := o["token"].(string)
|
|
label, _ := o["label"].(string)
|
|
label = strings.TrimSpace(label)
|
|
switch {
|
|
case !tokenShape.MatchString(tok):
|
|
return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok)
|
|
case seen[tok]:
|
|
return nil, fmt.Errorf("the token %q is given twice", tok)
|
|
case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="):
|
|
return nil, fmt.Errorf("an action's label is one short line without '=': %q", label)
|
|
}
|
|
seen[tok] = true
|
|
out = append(out, Choice{Token: tok, Label: label})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// actionArgs are notify-send's arguments for the choices, and its wait.
|
|
func actionArgs(choices []Choice) []string {
|
|
args := []string{"--wait"}
|
|
for _, c := range choices {
|
|
args = append(args, "--action="+c.Token+"="+c.Label)
|
|
}
|
|
return args
|
|
}
|
|
|
|
// sendWaiting runs notify-send with the choices, answers the id it prints first, and waits for the
|
|
// answer in the background: the next line it prints, if it is one of the tokens.
|
|
func (s Session) sendWaiting(args []string, choices []Choice) (SendResult, error) {
|
|
path, err := exec.LookPath("notify-send")
|
|
if err != nil {
|
|
return SendResult{}, errors.New("notify-send is not installed on this machine")
|
|
}
|
|
cmd := exec.Command(path, args...)
|
|
cmd.Env = s.Env()
|
|
if home := operatorHome(); exists(home) {
|
|
cmd.Dir = home
|
|
}
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
var errOut capped
|
|
cmd.Stderr = &errOut
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return SendResult{}, fmt.Errorf("notify-send: %w", err)
|
|
}
|
|
lines := bufio.NewReader(out)
|
|
first := make(chan string, 1)
|
|
go func() {
|
|
line, _ := lines.ReadString('\n')
|
|
first <- strings.TrimSpace(line)
|
|
}()
|
|
var idLine string
|
|
select {
|
|
case idLine = <-first:
|
|
case <-time.After(busTimeout):
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
_ = cmd.Wait()
|
|
return SendResult{}, fmt.Errorf("notify-send did not answer an id within %s: %w", busTimeout, ErrTimedOut)
|
|
}
|
|
id, err := strconv.Atoi(idLine)
|
|
if err != nil {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
_ = cmd.Wait()
|
|
return SendResult{}, fmt.Errorf("notify-send answered no id: %q %s", idLine, strings.TrimSpace(errOut.String()))
|
|
}
|
|
go awaitAnswer(cmd, lines, id, choices)
|
|
return SendResult{ID: id}, nil
|
|
}
|
|
|
|
// awaitAnswer reads what notify-send says when the notification ends, and says a chosen token.
|
|
func awaitAnswer(cmd *exec.Cmd, lines io.Reader, id int, choices []Choice) {
|
|
timer := time.AfterFunc(AnswerWait, func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) })
|
|
defer timer.Stop()
|
|
rest, _ := io.ReadAll(io.LimitReader(lines, 4096))
|
|
_ = cmd.Wait()
|
|
if tok, ok := chosenToken(string(rest), choices); ok {
|
|
body := map[string]any{"id": id, "token": tok, "machine": os.Getenv("MESH_NODE")}
|
|
if err := emit(ChosenEvent, body); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[dunst] the operator chose an action on notification %d and it could not be said: %v\n", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// chosenToken is the token notify-send printed after the id, when it is one offered.
|
|
func chosenToken(rest string, choices []Choice) (string, bool) {
|
|
for _, line := range strings.Split(rest, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
for _, c := range choices {
|
|
if line == c.Token {
|
|
return c.Token, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|