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.
411 lines
11 KiB
Go
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...)
|
|
}
|