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.
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user