Merge pull request 'dunst: reload the running notifier when the mesh rewrites its file' (#144) from fix/dunst-reloads-when-its-file-changes into main

This commit was merged in pull request #144.
This commit is contained in:
2026-10-08 20:12:23 +00:00
7 changed files with 231 additions and 8 deletions
+10 -4
View File
@@ -8,8 +8,12 @@ The notifier as a module (novox/hq ADR 0208, research 026/05).
rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after
`dunstrc`, so a drop-in outranks it.
- **Starts nothing.** The package registers dunst with D-Bus, which starts it on the first
notification, inside the account's service manager. There is no autostart line, no unit and no
session-start contribution.
notification, inside the account's service manager. There is no autostart line and no
session-start contribution. The package's own `dunst.service` is declared without a state, in the
account's manager (`scope: user`, ADR 0177), only so the node-engine can reload it: when the mesh
rewrites `dunstrc`, a running dunst is told to read it again (`reload-on`, the unit's
`ExecReload=dunstctl reload`). A dunst that is not running is left alone and reads the file when the bus
starts it (ADR 0117).
- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session
has, so it serves an X session and a later sway one alike.
@@ -38,6 +42,7 @@ the account is not logged in.
| `dunst_close_all` | clear the screen; the history keeps them |
| `dunst_rules` | the rules the running notifier holds, and the files they come from |
| `dunst_count` | shown, waiting, in history, and whether paused |
| `dunst_reload` | read the configuration again now, keeping what is shown; answers the files read and the font, font size and width they set, with the file each came from (dunst does not say its settings over the bus, so these are read from the same files) |
## Settings
@@ -54,8 +59,9 @@ A machine with a denser screen sets both for itself, and the next push writes th
mesh-controller.settings {module: "dunst", node: "<machine>", values: '{"font-size": 16, "width": 400}'}
`mesh-controller.settings {module: "dunst", node: "<machine>"}` shows each value and where it came from:
the default, the mesh, or the machine. dunst reads its file when it starts, and nothing in this module
tells a running dunst that the file changed: a new size shows at the next login, or after `dunstctl reload`.
the default, the mesh, or the machine. When the push rewrites `dunstrc`, the node-engine reloads the
running dunst, so the next notification is drawn at the new size, without a login. A reload keeps the
notifications on screen and in the history; a restart would drop them, which is why this is a reload.
## What it chose, and what it improves
+8
View File
@@ -88,6 +88,14 @@ func tools() []stdio.Tool {
"matches and what it sets — and the files they come from (the mesh's dunstrc, then dunstrc.d).",
Run: func(map[string]any) (any, error) { return Rules() },
},
{
Name: "dunst_reload",
Description: "Make the running notifier read its configuration again now (dunstrc, then dunstrc.d), " +
"keeping what is on screen and in the history. The mesh does this by itself when it rewrites " +
"dunstrc, for instance after a font-size or width setting changes. Answers the files read and the " +
"font, font size and width they set, with the file each came from.",
Run: func(map[string]any) (any, error) { return Reload() },
},
{
Name: "dunst_count",
Description: "How many notifications are shown, waiting and in the history, and whether do-not-disturb is on.",
@@ -8,7 +8,7 @@ import (
// dunst's shape (novox/hq ADR 0208): it claims node-notifier serving send and history, owns its
// dunstrc and the drop-in directory other modules' rules go in, and starts nothing — D-Bus starts it
// on the first notification. It draws only once a notification arrives, through the bus's
// on the first notification; the mesh only tells a running one to read its file again. It draws only once a notification arrives, through the bus's
// activation, so it requires no display of its own.
func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) {
@@ -90,7 +90,9 @@ func TestNothingStartsItButTheBus(t *testing.T) {
t.Fatalf("a session start: %+v", m.Shell)
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
// The one unit it names is the package's own dunst.service, without a state: its lifecycle stays
// the bus's, and the mesh only reloads it when it is running and its file changed (ADR 0117).
if r["type"] == "process" || (r["type"] == "service" && (r["state"] != nil || r["boot"] != nil)) {
t.Fatalf("a unit: %v", r)
}
if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "i3/config.d") {
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
// Reading the configuration again (novox/hq ADR 0262). A running dunst reads its files only when it
// starts or is told to reload; the node-engine tells it through the account's dunst.service (its
// ExecReload is `dunstctl reload`) whenever the mesh rewrites dunstrc, and dunst_reload tells it now.
// A reload keeps the notifications on screen and in the history; a restart would lose them.
// Globals are the [global] font and width the configuration files set, and which file set each.
type Globals struct {
Font string `json:"font,omitempty"`
FontSize int `json:"font_size,omitempty"`
FontFrom string `json:"font_from,omitempty"`
Width string `json:"width,omitempty"`
WidthFrom string `json:"width_from,omitempty"`
}
// ReloadResult is what dunst_reload answers.
type ReloadResult struct {
Reloaded bool `json:"reloaded"`
Files []string `json:"files"`
Globals
Displayed int `json:"displayed"`
Note string `json:"note"`
}
// Reload tells the running dunst to read its configuration again, shows it still answers, and says
// the font and width the files it read set.
func Reload() (ReloadResult, error) {
// No files named: dunst reads its own search path, dunstrc and then dunstrc.d, as at its start.
if _, err := dunstctl("reload"); err != nil {
return ReloadResult{}, err
}
shown, err := dunstctl("count", "displayed")
if err != nil {
return ReloadResult{}, err
}
displayed, _ := strconv.Atoi(strings.TrimSpace(shown))
files := configFiles(filepath.Join(operatorHome(), ".config", "dunst"))
return ReloadResult{
Reloaded: true,
Files: files,
Globals: globalsOf(files),
Displayed: displayed,
Note: "dunst read these files again and still answers. dunst does not answer its settings over " +
"the bus, so font and width are what the files it read set (the last file that sets one wins); " +
"the next notification is drawn with them",
}, nil
}
// globalsOf reads the [global] font and width from the files in the order dunst reads them; a later
// file outranks an earlier one, and a rule's own font is the rule's, not the global one.
func globalsOf(files []string) Globals {
var g Globals
for _, path := range files {
f, err := os.Open(path)
if err != nil {
continue
}
section := ""
lines := bufio.NewScanner(f)
for lines.Scan() {
line := strings.TrimSpace(lines.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = strings.TrimSpace(line[1 : len(line)-1])
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok || section != "global" {
continue
}
value = strings.Trim(strings.TrimSpace(value), `"`)
switch strings.TrimSpace(key) {
case "font":
g.Font, g.FontFrom, g.FontSize = value, path, 0
if fields := strings.Fields(value); len(fields) > 0 {
if n, err := strconv.Atoi(fields[len(fields)-1]); err == nil {
g.FontSize = n
}
}
case "width":
g.Width, g.WidthFrom = value, path
}
}
f.Close()
}
return g
}
@@ -0,0 +1,96 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The running dunst reads its file again when the file changes (novox/hq ADR 0262: a setting the
// operator changes shows at the next notification, not at the next login). The node-engine reloads
// the account's dunst.service, whose ExecReload is `dunstctl reload`; dunst_reload does the same now.
// writeConfig puts a dunstrc and drop-ins under a fake home's ~/.config/dunst.
func writeConfig(t *testing.T, home string, files map[string]string) {
t.Helper()
for name, body := range files {
path := filepath.Join(home, ".config", "dunst", name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestTheGlobalsAreTheLastFileThatSetsThem(t *testing.T) {
home := t.TempDir()
writeConfig(t, home, map[string]string{
"dunstrc": "[global]\n width = 500\n height = (0, 300)\n font = Inter 13\n" +
"[urgency_low]\n font = Mono 99\n",
// A rule's font is the rule's, not the global one; a later [global] in a drop-in outranks dunstrc.
"dunstrc.d/10-slack.conf": "[slack]\n appname = Slack\n font = \"Comic 40\"\n",
"dunstrc.d/90-big.conf": "# a comment\n[global]\n font = \"Inter 16\"\n",
})
got := globalsOf(configFiles(filepath.Join(home, ".config", "dunst")))
if got.Font != "Inter 16" || got.FontSize != 16 || got.Width != "500" {
t.Fatalf("%+v", got)
}
if !strings.HasSuffix(got.FontFrom, "dunstrc.d/90-big.conf") || !strings.HasSuffix(got.WidthFrom, "/dunstrc") {
t.Fatalf("from: %+v", got)
}
}
func TestReloadAsksTheRunningDunstToReadItsFilesAndSaysWhatTheySet(t *testing.T) {
root := withBus(t)
writeConfig(t, filepath.Join(root, "home"), map[string]string{
"dunstrc": "[global]\n width = 500\n font = Inter 13\n",
})
bin := fakeBinaries(t, map[string]string{"dunstctl": `echo "$*" >> "$LOG"
case "$*" in
count*) echo 0 ;;
is-paused) echo false ;;
esac`})
t.Setenv("LOG", filepath.Join(bin, "log"))
got, err := Reload()
if err != nil {
t.Fatal(err)
}
if !got.Reloaded || got.Font != "Inter 13" || got.FontSize != 13 || got.Width != "500" || len(got.Files) != 1 {
t.Fatalf("%+v", got)
}
if !strings.Contains(got.Note, "does not answer") {
t.Fatalf("the note does not say where the values come from: %q", got.Note)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
asked := strings.Split(strings.TrimSpace(string(log)), "\n")
// reload first, with no files named (dunst reads its own search path, drop-ins included), and then
// asked something to show it still answers.
if asked[0] != "reload" || len(asked) < 2 {
t.Fatalf("dunstctl was asked: %q", asked)
}
}
func TestAReloadDunstRefusesIsAFailure(t *testing.T) {
withBus(t)
fakeBinaries(t, map[string]string{"dunstctl": `echo "Failed to communicate with dunst" >&2; exit 1`})
if _, err := Reload(); err == nil || !strings.Contains(err.Error(), "Failed to communicate") {
t.Fatalf("%v", err)
}
}
func TestTheRunningDunstIsReloadedWhenItsFileChanges(t *testing.T) {
m := readManifest(t)
s := m.resource(t, "notifier")
want := map[string]any{
"id": "notifier", "type": "service", "unit": "dunst.service",
"scope": "user", "user": "${machine:account}",
"reload-on": []any{"configuration"},
}
if !reflect.DeepEqual(s, want) {
t.Fatalf("notifier: %v\nwant %v", s, want)
}
}
+2
View File
@@ -5,6 +5,8 @@
#
# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,
# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.
# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst
# to read it again (its unit's reload, dunstctl reload); what is on screen stays.
[global]
monitor = 0
+13 -2
View File
@@ -19,7 +19,8 @@
"dunst_resume",
"dunst_close_all",
"dunst_rules",
"dunst_count"
"dunst_count",
"dunst_reload"
],
"settings": {
"font-size": {
@@ -64,7 +65,17 @@
"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\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"
},
{
"id": "notifier",
"type": "service",
"unit": "dunst.service",
"scope": "user",
"user": "${machine:account}",
"reload-on": [
"configuration"
]
}
],
"build": {