Files
mesh-catalog/modules/dunst/cmd/dunst-tools/actions.go
T
jochen f00b5a19b1
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 delivered
mesh/delivery-group group feat/plain-notifications delivered: every member is delivered
Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
The operator could not tell from a notification whether to act, nor act without
opening a session. Each message now opens with a verdict, a warning that needs
nothing is not sent, and a notification offers answers the messenger performs
through their seat verbs; dunst's send gains actions and says the chosen one
(hq ADR 0253).
2026-10-08 13:57:33 +02:00

169 lines
5.5 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.
ChosenEvent = "action-chosen"
)
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
}