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 }