Say what the operator needs to do, keep quiet what needs nothing, and take answers from the desk
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
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
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).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A notification offers answers, and the chosen one is said as an event** (novox/hq ADR 0253): the id
|
||||
// is answered at once, the answer comes when the operator chooses, and nothing is said for a
|
||||
// notification closed without one.
|
||||
func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
|
||||
withBus(t)
|
||||
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
t.Setenv("MESH_NODE", "g14")
|
||||
var mu sync.Mutex
|
||||
var said []map[string]any
|
||||
before := emit
|
||||
emit = func(event string, body any) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if event != ChosenEvent {
|
||||
t.Errorf("event %q", event)
|
||||
}
|
||||
said = append(said, body.(map[string]any))
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { emit = before })
|
||||
|
||||
n, err := notificationOf(map[string]any{"summary": "Delivery of hq held for 36 hours", "body": "Needs you: release it, or stop it.",
|
||||
"actions": []any{map[string]any{"token": "a1b2", "label": "Release"}, map[string]any{"token": "c3d4", "label": "Stop"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CHOSEN", "c3d4")
|
||||
start := time.Now()
|
||||
got, err := Send(n)
|
||||
if err != nil || got.ID != 42 || time.Since(start) > 150*time.Millisecond {
|
||||
t.Fatalf("%+v, %v, after %s: the id is answered before the answer", got, err, time.Since(start))
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
if !strings.Contains(string(asked), "[--wait][--action=a1b2=Release][--action=c3d4=Stop][--]") {
|
||||
t.Fatalf("notify-send was asked %s", asked)
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(said)
|
||||
mu.Unlock()
|
||||
if n > 0 || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
|
||||
t.Fatalf("said %v", said)
|
||||
}
|
||||
said = nil
|
||||
mu.Unlock()
|
||||
|
||||
// Closed without an answer, or an answer nobody offered: nothing is said.
|
||||
for _, chosen := range []string{"", "zzzz"} {
|
||||
t.Setenv("CHOSEN", chosen)
|
||||
if _, err := Send(n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(said) != 0 {
|
||||
t.Fatalf("an answer was invented: %v", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
|
||||
for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}},
|
||||
[]any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}},
|
||||
[]any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}},
|
||||
[]any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"},
|
||||
map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"},
|
||||
map[string]any{"token": "e", "label": "5"}}} {
|
||||
if _, err := notificationOf(map[string]any{"summary": "x", "actions": bad}); err == nil {
|
||||
t.Errorf("accepted %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type Notification struct {
|
||||
ExpireMS int
|
||||
Category string
|
||||
ReplaceID int
|
||||
Actions []Choice
|
||||
}
|
||||
|
||||
func notificationOf(args map[string]any) (Notification, error) {
|
||||
@@ -66,7 +67,10 @@ func notificationOf(args map[string]any) (Notification, error) {
|
||||
if n.Category, err = text(args, "category", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
|
||||
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.Actions, err = choicesOf(args)
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -94,11 +98,17 @@ func Send(n Notification) (SendResult, error) {
|
||||
if n.ReplaceID > 0 {
|
||||
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
|
||||
}
|
||||
if len(n.Actions) > 0 {
|
||||
args = append(args, actionArgs(n.Actions)...)
|
||||
}
|
||||
// "--" so a title that starts with a dash is a title.
|
||||
args = append(args, "--", n.Summary)
|
||||
if n.Body != "" {
|
||||
args = append(args, n.Body)
|
||||
}
|
||||
if len(n.Actions) > 0 {
|
||||
return s.sendWaiting(args, n.Actions)
|
||||
}
|
||||
r, err := s.run(busTimeout, "", "notify-send", args...)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
|
||||
@@ -22,8 +22,9 @@ func tools() []stdio.Tool {
|
||||
{
|
||||
Name: "node-notifier.send",
|
||||
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
|
||||
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
|
||||
"Answers the notification's id.",
|
||||
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
|
||||
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
|
||||
"the operator chooses is said later as the event action-chosen {id, token, machine}.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
@@ -35,6 +36,10 @@ func tools() []stdio.Tool {
|
||||
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
|
||||
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
|
||||
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
|
||||
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
|
||||
"each {token, label}; the chosen token is said as the event action-chosen",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"token": map[string]any{"type": "string"}, "label": map[string]any{"type": "string"}}}},
|
||||
},
|
||||
"required": []string{"summary"},
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ type manifest struct {
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Emits []string `json:"emits"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
|
||||
Reference in New Issue
Block a user