diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 53661357..6c288e2f 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -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 diff --git a/modules/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go index a9991095..3ef707cc 100644 --- a/modules/dunst/cmd/dunst-tools/dunst.go +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -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"]), diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index dc493989..547e9cab 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -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)"}, }, diff --git a/modules/dunst/cmd/dunst-tools/paused.go b/modules/dunst/cmd/dunst-tools/paused.go new file mode 100644 index 00000000..8d55ac29 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/paused.go @@ -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) + } +} diff --git a/modules/dunst/cmd/dunst-tools/secret_test.go b/modules/dunst/cmd/dunst-tools/secret_test.go new file mode 100644 index 00000000..728e2169 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/secret_test.go @@ -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) + } +} diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index 705a1f42..727a12e1 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -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 diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 7c37c2ae..37c0adfa 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -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 = \"%s\\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 = \"%s\\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" ] }