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.
This commit is contained in:
2026-10-09 17:08:17 +02:00
parent 711e674ab1
commit c161521e49
7 changed files with 231 additions and 5 deletions
+10
View File
@@ -29,6 +29,16 @@ ends the waits it held; it says `started` {machine} once it serves, and the oper
open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a
pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired).
## What is shown once, and whether it would be seen
- **A secret notification** (`send` with `secret`: a link's code) is sent transient and in the category
`mesh.secret`, whatever category was named. The dunstrc's rules keep it, and every transient one, out of
dunst's history (`history_ignore`), and `history` leaves out any that got there anyway, counting them as
`withheld`: the history is served to every caller of the mesh's verbs, agents among them.
- **Whether notifications are paused** is said as this module's event `paused` {paused, machine}: at start,
on every change, and every two minutes, read from dunst itself so a pause set by a key counts too. The desk
shows a link's code only on a machine it heard is not paused lately.
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
+41 -1
View File
@@ -28,8 +28,16 @@ type Notification struct {
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
@@ -70,10 +78,32 @@ func notificationOf(args map[string]any) (Notification, error) {
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"`
@@ -100,6 +130,9 @@ func Send(n Notification) (SendResult, error) {
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))
}
@@ -187,6 +220,8 @@ type Shown struct {
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.
@@ -203,13 +238,18 @@ func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
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"]),
+7 -2
View File
@@ -12,6 +12,7 @@ import (
func main() {
go sayStarted()
go watchPaused()
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -25,7 +26,8 @@ func tools() []stdio.Tool {
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, 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}.",
"the operator chooses is said later as the event action-chosen {id, token, machine}. A secret " +
"or transient one is kept out of the history.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -37,6 +39,9 @@ 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"},
"transient": map[string]any{"type": "boolean", "description": "keep it out of the history (default false)"},
"secret": map[string]any{"type": "boolean", "description": "it carries something shown once, such as a " +
"link's code: transient, in the category " + SecretCategory + ", and never in the history (default false)"},
"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{
@@ -55,7 +60,7 @@ func tools() []stdio.Tool {
{
Name: "node-notifier.history",
Description: "The notifications the operator was shown, newest first: id, application, title, " +
"body, urgency and how long ago.",
"body, urgency and how long ago. A secret one (a link's code) is never shown; withheld counts them.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
},
+71
View File
@@ -0,0 +1,71 @@
package main
// Whether notifications are paused, said as this module's event `paused` {paused, machine} (the review of
// 2026-10-09, C4): at start, on every change, and again every PausedEvery. The desk shows a link's code only on
// a machine it heard is not paused lately, and says it cannot send one while none is: a code held back by a
// paused notifier would appear later, to whoever is then at the screen. The pause is read from dunst itself
// (`dunstctl is-paused`), so one set by a key or by hand is said like one set by dunst_pause.
import (
"fmt"
"os"
"strings"
"time"
)
const (
// PausedEvent is the event's name; PausedEvery how often it is said when nothing changed; PausedLook how
// often dunst is asked.
PausedEvent = "paused"
PausedEvery = 2 * time.Minute
PausedLook = 5 * time.Second
)
// pauseWatch says the pause; its two reaches are seams a test replaces.
type pauseWatch struct {
isPaused func() (bool, error)
say func(paused bool) error
saidAt time.Time
said bool
known bool
}
// step looks once, and says it when it changed or was said PausedEvery ago. Nothing is said while dunst
// cannot be asked: the desk then hears nothing, and takes the machine as not known.
func (w *pauseWatch) step(now time.Time) {
paused, err := w.isPaused()
if err != nil {
return
}
if w.known && paused == w.said && now.Sub(w.saidAt) < PausedEvery {
return
}
if w.say(paused) == nil {
w.known, w.said, w.saidAt = true, paused, now
}
}
func isPaused() (bool, error) {
out, err := dunstctl("is-paused")
if err != nil {
return false, err
}
switch strings.TrimSpace(out) {
case "true":
return true, nil
case "false":
return false, nil
}
return false, fmt.Errorf("dunstctl is-paused answered %q", out)
}
// watchPaused runs the watch for as long as the bundle runs.
func watchPaused() {
w := &pauseWatch{isPaused: isPaused, say: func(p bool) error {
return emit(PausedEvent, map[string]any{"paused": p, "machine": os.Getenv("MESH_NODE")})
}}
for {
w.step(time.Now())
time.Sleep(PausedLook)
}
}
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A notification carrying something shown once — a link's code — is sent transient and in the category
// mesh.secret, whatever category the caller names (the review of 2026-10-09, H4): the dunstrc's rules keep
// both out of dunst's history, and the history verb leaves out any that got there anyway, so no agent reads
// the code back through node-notifier.history.
func TestASecretNotificationIsTransientAndKeptOutOfTheHistory(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42`})
t.Setenv("LOG", filepath.Join(bin, "log"))
n, err := notificationOf(map[string]any{"summary": "Link code", "body": "123 456", "secret": true, "category": "email"})
if err != nil {
t.Fatal(err)
}
if _, err := Send(n); err != nil {
t.Fatal(err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
for _, want := range []string{"[--transient]", "[--category=" + SecretCategory + "]"} {
if !strings.Contains(string(asked), want) {
t.Errorf("notify-send was not asked %s: %s", want, asked)
}
}
if strings.Contains(string(asked), "--category=email") {
t.Error("a secret notification kept the caller's category")
}
if n, err := notificationOf(map[string]any{"summary": "x", "transient": true}); err != nil || !n.Transient || n.Secret {
t.Errorf("transient alone: %+v %v", n, err)
}
// The history leaves a secret one out, and says how many it left out.
withSecret := strings.Replace(history, `"category":{"type":"s","data":"device"}`,
`"category":{"type":"s","data":"`+SecretCategory+`"}`, 1)
got, err := parseHistory(withSecret, 200_000_000, 20)
if err != nil {
t.Fatal(err)
}
if len(got.Notifications) != 1 || got.Notifications[0].ID != 7 || got.Total != 1 || got.Withheld != 1 {
t.Fatalf("the history showed a secret notification: %+v", got)
}
// The decided file holds the rules that keep them out of dunst's own history.
rc, err := os.ReadFile(filepath.Join("..", "..", "files", "dunstrc"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"category = \"" + SecretCategory + "\"", "match_transient = yes", "history_ignore = yes"} {
if !strings.Contains(string(rc), want) {
t.Errorf("dunstrc does not say %q", want)
}
}
}
// Whether notifications are paused is said as the event `paused` {paused, machine}: at once, on every change,
// and again every PausedEvery, so the desk knows a link's code would not be seen (C4); nothing is said while
// dunst cannot be asked.
func TestWhetherPausedIsSaidOnChangeAndEveryFewMinutes(t *testing.T) {
var said []bool
paused, fails := false, false
w := pauseWatch{
isPaused: func() (bool, error) {
if fails {
return false, os.ErrNotExist
}
return paused, nil
},
say: func(p bool) error { said = append(said, p); return nil },
}
at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
w.step(at)
w.step(at.Add(5 * time.Second))
paused = true
w.step(at.Add(10 * time.Second))
w.step(at.Add(10*time.Second + PausedEvery))
fails = true
w.step(at.Add(10*time.Second + 3*PausedEvery))
if len(said) != 3 || said[0] || !said[1] || !said[2] {
t.Fatalf("said %v", said)
}
}
+11
View File
@@ -92,3 +92,14 @@
foreground = "#ffffff"
frame_color = "#ff0000"
timeout = 0
# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's
# code, category mesh.secret) and any transient one leave no entry in the history, which
# node-notifier.history serves to every caller of the mesh's verbs.
[mesh-secret]
category = "mesh.secret"
history_ignore = yes
[transient-history-ignore]
match_transient = yes
history_ignore = yes
+3 -2
View File
@@ -66,7 +66,7 @@
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n"
},
{
"id": "notifier",
@@ -96,6 +96,7 @@
},
"emits": [
"action-chosen",
"started"
"started",
"paused"
]
}