From 9d438184be3cf1a2b1df33c04fdabadc643ea1b1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 22:08:08 +0200 Subject: [PATCH] dunst: reload the running notifier when the mesh rewrites its file A setting changed through mesh-controller.settings rewrote dunstrc, but the running dunst kept the old font and width until the next login. Declare the package's dunst.service in the account's manager, without a state, with reload-on the configuration, so the node-engine runs its ExecReload (dunstctl reload) and what is on screen stays. Add dunst_reload to do the same on demand and say the font and width the files set. --- modules/dunst/README.md | 14 ++- modules/dunst/cmd/dunst-tools/main.go | 8 ++ .../dunst/cmd/dunst-tools/manifest_test.go | 6 +- modules/dunst/cmd/dunst-tools/reload.go | 98 +++++++++++++++++++ modules/dunst/cmd/dunst-tools/reload_test.go | 96 ++++++++++++++++++ modules/dunst/files/dunstrc | 2 + modules/dunst/module.json | 15 ++- 7 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 modules/dunst/cmd/dunst-tools/reload.go create mode 100644 modules/dunst/cmd/dunst-tools/reload_test.go diff --git a/modules/dunst/README.md b/modules/dunst/README.md index c7137ee3..53661357 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -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: "", values: '{"font-size": 16, "width": 400}'} `mesh-controller.settings {module: "dunst", node: ""}` 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 diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go index ee45021b..dc493989 100644 --- a/modules/dunst/cmd/dunst-tools/main.go +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -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.", diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go index 56313353..60c1c9a2 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -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") { diff --git a/modules/dunst/cmd/dunst-tools/reload.go b/modules/dunst/cmd/dunst-tools/reload.go new file mode 100644 index 00000000..9d648bcb --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/reload.go @@ -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 +} diff --git a/modules/dunst/cmd/dunst-tools/reload_test.go b/modules/dunst/cmd/dunst-tools/reload_test.go new file mode 100644 index 00000000..dc31bb57 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/reload_test.go @@ -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) + } +} diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index d9bc6ad9..705a1f42 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -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 diff --git a/modules/dunst/module.json b/modules/dunst/module.json index df8130eb..1ba23d92 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -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 = \"%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" + }, + { + "id": "notifier", + "type": "service", + "unit": "dunst.service", + "scope": "user", + "user": "${machine:account}", + "reload-on": [ + "configuration" + ] } ], "build": {