mesh/merge-gate fail: builds dunst → g14, shanks; no bus step; 2 problem(s) the change brings; the first: a machine (f35): nothing could be sent to it with…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The actions sat on the middle click, which the operator's touchpads cannot give, and the context menu named dmenu, which dunst's PATH does not hold. The left click is now do_action, close_current (one action runs, several open the menu, none closes), the menu is rofi, declared as a package, Super+N opens it from the keyboard through i3's contribution, and send refuses the token default, which dunst would run at a tap without the menu.
190 lines
6.6 KiB
Go
190 lines
6.6 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 tap (left click) runs the only one, or opens
|
|
// rofi's menu of them when there are several (the dunstrc's do_action); 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 tok == "default":
|
|
// dunst's do_action runs an action named "default" at a tap even when there are others, so a
|
|
// tap would choose it without the menu; the name is dunst's, not a token's.
|
|
return nil, errors.New(`an action's token may not be "default", which dunst runs at a tap without offering the others`)
|
|
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
|
|
}
|