Files
mesh-catalog/modules/dunst/cmd/dunst-tools/dunst.go
T
jschoubben c161521e49 dunst: keep what is shown once out of the history, and say whether notifications are paused
The review of 2026-10-09 (H4): a link's code shown on the desk stayed in dunst's history, which
node-notifier.history serves to every caller of the mesh's verbs, agents among them; and a code sent to a
paused notifier is held and shown later, to whoever is then at the screen. send now takes secret (transient,
category mesh.secret) and transient; the dunstrc's rules keep both out of the history, and history leaves out
any that got there. The pause, read from dunst itself, is said as the event paused at start, on change and
every two minutes, for the desk to read.
2026-10-09 17:08:17 +02:00

411 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
)
var urgencies = []string{"low", "normal", "critical"}
const busTimeout = 10 * time.Second
// Notification is what node-notifier.send shows.
type Notification struct {
Summary string
Body string
Urgency string
AppName string
Icon string
ExpireMS int
Category string
ReplaceID int
Actions []Choice
// Transient asks dunst to keep it out of its history; Secret marks something shown once (a link's code):
// transient, and in SecretCategory whatever category was named, which the dunstrc keeps out of the
// history and the history verb leaves out (the review of 2026-10-09).
Transient bool
Secret bool
}
// SecretCategory is the category a secret notification is sent in, and the one the history never shows.
const SecretCategory = "mesh.secret"
func notificationOf(args map[string]any) (Notification, error) {
var n Notification
var err error
if n.Summary, err = text(args, "summary", true); err != nil {
return n, err
}
if n.Body, err = text(args, "body", false); err != nil {
return n, err
}
if n.Urgency, err = text(args, "urgency", false); err != nil {
return n, err
}
if n.Urgency == "" {
n.Urgency = "normal"
}
known := false
for _, u := range urgencies {
known = known || u == n.Urgency
}
if !known {
return n, fmt.Errorf("urgency %q is low, normal or critical", n.Urgency)
}
if n.AppName, err = text(args, "app_name", false); err != nil {
return n, err
}
if n.AppName == "" {
n.AppName = "mesh"
}
if n.Icon, err = text(args, "icon", false); err != nil {
return n, err
}
if n.ExpireMS, err = whole(args, "expire_ms", -1, 0, 24*3600*1000); err != nil {
return n, err
}
if n.Category, err = text(args, "category", false); err != nil {
return n, err
}
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
return n, err
}
if n.Transient, err = boolean(args, "transient"); err != nil {
return n, err
}
if n.Secret, err = boolean(args, "secret"); err != nil {
return n, err
}
if n.Secret {
n.Transient, n.Category = true, SecretCategory
}
n.Actions, err = choicesOf(args)
return n, err
}
// boolean reads an optional true or false.
func boolean(args map[string]any, key string) (bool, error) {
v, present := args[key]
if !present || v == nil {
return false, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false", key)
}
return b, nil
}
// SendResult is what node-notifier.send answers.
type SendResult struct {
ID int `json:"id"`
}
// Send shows a notification through the desktop's notification service, whichever runs it.
func Send(n Notification) (SendResult, error) {
s, err := findBus()
if err != nil {
return SendResult{}, err
}
args := []string{"--print-id", "--urgency=" + n.Urgency, "--app-name=" + n.AppName}
if n.Icon != "" {
args = append(args, "--icon="+n.Icon)
}
if len(n.Actions) > 0 && n.ExpireMS < 0 {
// A notification that offers answers stays until it is dismissed: a warning's answers must not die
// with dunst's ten seconds (novox/hq ADR 0258).
n.ExpireMS = 0
}
if n.ExpireMS >= 0 {
args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS))
}
if n.Category != "" {
args = append(args, "--category="+n.Category)
}
if n.Transient {
args = append(args, "--transient")
}
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
}
if r.Code != 0 {
return SendResult{}, fmt.Errorf("notify-send: %s", strings.TrimSpace(r.Stderr))
}
id, err := strconv.Atoi(strings.TrimSpace(r.Stdout))
if err != nil {
return SendResult{}, fmt.Errorf("notify-send answered no id: %q", r.Stdout)
}
return SendResult{ID: id}, nil
}
// dunstctl runs one dunstctl command over the session bus and answers what it printed.
func dunstctl(args ...string) (string, error) {
s, err := findBus()
if err != nil {
return "", err
}
r, err := s.run(busTimeout, "", "dunstctl", args...)
if err != nil {
return "", err
}
if r.Code != 0 {
return "", fmt.Errorf("dunstctl %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr+r.Stdout))
}
return r.Stdout, nil
}
// variantMaps reads busctl's JSON form of an array of dictionaries (aa{sv}), which is how dunstctl
// answers history and rules, into plain maps.
func variantMaps(raw string) ([]map[string]any, error) {
var doc struct {
Type string `json:"type"`
Data [][]map[string]struct {
Data any `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return nil, fmt.Errorf("dunstctl's answer is not the bus's JSON: %w", err)
}
if doc.Type != "aa{sv}" {
return nil, fmt.Errorf("dunstctl answered %s, not aa{sv}", doc.Type)
}
out := []map[string]any{}
for _, group := range doc.Data {
for _, entry := range group {
m := map[string]any{}
for k, v := range entry {
m[k] = v.Data
}
out = append(out, m)
}
}
return out, nil
}
// Shown is one notification in the history.
type Shown struct {
ID int `json:"id"`
AppName string `json:"app_name"`
Summary string `json:"summary"`
Body string `json:"body,omitempty"`
Urgency string `json:"urgency"`
Category string `json:"category,omitempty"`
AgeSeconds int64 `json:"age_seconds"`
}
// HistoryResult is what node-notifier.history answers.
type HistoryResult struct {
Total int `json:"total"`
Notifications []Shown `json:"notifications"`
// Withheld is how many secret notifications the history held and this left out.
Withheld int `json:"withheld,omitempty"`
}
// History is dunst's history, newest first.
func History(limit int) (HistoryResult, error) {
raw, err := dunstctl("history")
if err != nil {
return HistoryResult{}, err
}
return parseHistory(raw, monotonicMicros(), limit)
}
func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Notifications: []Shown{}}
type stamped struct {
Shown
at int64
}
var all []stamped
for _, e := range entries {
if str(e["category"]) == SecretCategory {
out.Withheld++
continue
}
out.Total++
at := number(e["timestamp"])
all = append(all, stamped{Shown{
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
Urgency: strings.ToLower(str(e["urgency"])), Category: str(e["category"]),
AgeSeconds: max(0, (nowMicros-at)/1_000_000),
}, at})
}
sort.SliceStable(all, func(i, j int) bool { return all[i].at > all[j].at })
for i, s := range all {
if i == limit {
break
}
out.Notifications = append(out.Notifications, s.Shown)
}
return out, nil
}
func number(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case json.Number:
i, _ := n.Int64()
return i
}
return 0
}
func str(v any) string {
s, _ := v.(string)
return s
}
// monotonicMicros is the clock dunst stamps its notifications with (CLOCK_MONOTONIC, microseconds).
func monotonicMicros() int64 {
var ts syscall.Timespec
const clockMonotonic = 1
if _, _, errno := syscall.Syscall(syscall.SYS_CLOCK_GETTIME, clockMonotonic, uintptr(unsafe.Pointer(&ts)), 0); errno != 0 {
return 0
}
return ts.Sec*1_000_000 + ts.Nsec/1000
}
// PauseResult is what dunst_pause and dunst_resume answer.
type PauseResult struct {
Paused bool `json:"paused"`
Note string `json:"note"`
}
// SetPaused turns do-not-disturb on or off.
func SetPaused(on bool) (PauseResult, error) {
if _, err := dunstctl("set-paused", strconv.FormatBool(on)); err != nil {
return PauseResult{}, err
}
out, err := dunstctl("is-paused")
if err != nil {
return PauseResult{}, err
}
paused := strings.TrimSpace(out) == "true"
note := "notifications are shown"
if paused {
note = "notifications are held back until dunst_resume; the next login starts unpaused"
}
return PauseResult{Paused: paused, Note: note}, nil
}
// CloseAll closes what is on screen.
func CloseAll() (CountResult, error) {
if _, err := dunstctl("close-all"); err != nil {
return CountResult{}, err
}
return Count()
}
// CountResult is what dunst_count answers.
type CountResult struct {
Displayed int `json:"displayed"`
Waiting int `json:"waiting"`
History int `json:"history"`
Paused bool `json:"paused"`
}
// Count is how many notifications are where.
func Count() (CountResult, error) {
var c CountResult
for _, part := range []struct {
which string
into *int
}{{"displayed", &c.Displayed}, {"waiting", &c.Waiting}, {"history", &c.History}} {
out, err := dunstctl("count", part.which)
if err != nil {
return c, err
}
n, err := strconv.Atoi(strings.TrimSpace(out))
if err != nil {
return c, fmt.Errorf("dunstctl count %s answered %q", part.which, out)
}
*part.into = n
}
out, err := dunstctl("is-paused")
if err != nil {
return c, err
}
c.Paused = strings.TrimSpace(out) == "true"
return c, nil
}
// Rule is one notifier rule in force.
type Rule struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Sets map[string]any `json:"sets"`
}
// RulesResult is what dunst_rules answers.
type RulesResult struct {
Files []string `json:"files"`
Rules []Rule `json:"rules"`
}
// Rules are the rules the running notifier holds, and the files it reads them from.
func Rules() (RulesResult, error) {
raw, err := dunstctl("rules", "--json")
if err != nil {
return RulesResult{}, err
}
return parseRules(raw, configFiles(filepath.Join(operatorHome(), ".config", "dunst")))
}
func parseRules(raw string, files []string) (RulesResult, error) {
entries, err := variantMaps(raw)
if err != nil {
return RulesResult{}, err
}
out := RulesResult{Files: files, Rules: []Rule{}}
for _, e := range entries {
r := Rule{Name: str(e["name"]), Enabled: e["enabled"] == true, Sets: map[string]any{}}
for k, v := range e {
if k != "name" && k != "enabled" {
r.Sets[k] = v
}
}
out.Rules = append(out.Rules, r)
}
sort.SliceStable(out.Rules, func(i, j int) bool { return out.Rules[i].Name < out.Rules[j].Name })
return out, nil
}
// configFiles are dunstrc and its drop-ins in the order dunst reads them.
func configFiles(dir string) []string {
files := []string{}
if _, err := os.Stat(filepath.Join(dir, "dunstrc")); err == nil {
files = append(files, filepath.Join(dir, "dunstrc"))
}
dropins, _ := filepath.Glob(filepath.Join(dir, "dunstrc.d", "*.conf"))
sort.Strings(dropins)
return append(files, dropins...)
}